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;
21 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
23 # because they load configuration by setting FS::UID::callback (see TODO)
29 # for sending cancel emails in sub cancel
32 @ISA = qw( FS::cust_main_Mixin FS::Record );
36 $disable_agentcheck = 0;
40 my ( $hashref, $cache ) = @_;
41 #if ( $hashref->{'pkgpart'} ) {
42 if ( $hashref->{'pkg'} ) {
43 # #@{ $self->{'_pkgnum'} } = ();
44 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
45 # $self->{'_pkgpart'} = $subcache;
46 # #push @{ $self->{'_pkgnum'} },
47 # FS::part_pkg->new_or_cached($hashref, $subcache);
48 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
50 if ( exists $hashref->{'svcnum'} ) {
51 #@{ $self->{'_pkgnum'} } = ();
52 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
53 $self->{'_svcnum'} = $subcache;
54 #push @{ $self->{'_pkgnum'} },
55 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
61 FS::cust_pkg - Object methods for cust_pkg objects
67 $record = new FS::cust_pkg \%hash;
68 $record = new FS::cust_pkg { 'column' => 'value' };
70 $error = $record->insert;
72 $error = $new_record->replace($old_record);
74 $error = $record->delete;
76 $error = $record->check;
78 $error = $record->cancel;
80 $error = $record->suspend;
82 $error = $record->unsuspend;
84 $part_pkg = $record->part_pkg;
86 @labels = $record->labels;
88 $seconds = $record->seconds_since($timestamp);
90 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
91 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
95 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
96 inherits from FS::Record. The following fields are currently supported:
100 =item pkgnum - primary key (assigned automatically for new billing items)
102 =item custnum - Customer (see L<FS::cust_main>)
104 =item pkgpart - Billing item definition (see L<FS::part_pkg>)
108 =item bill - date (next bill date)
110 =item last_bill - last bill date
118 =item otaker - order taker (assigned automatically if null, see L<FS::UID>)
120 =item manual_flag - If this field is set to 1, disables the automatic
121 unsuspension of this package when using the B<unsuspendauto> config file.
125 Note: setup, bill, susp, expire and cancel are specified as UNIX timestamps;
126 see L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for
127 conversion functions.
135 Create a new billing item. To add the item to the database, see L<"insert">.
139 sub table { 'cust_pkg'; }
140 sub cust_linked { $_[0]->cust_main_custnum; }
141 sub cust_unlinked_msg {
143 "WARNING: can't find cust_main.custnum ". $self->custnum.
144 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
147 =item insert [ OPTION => VALUE ... ]
149 Adds this billing item to the database ("Orders" the item). If there is an
150 error, returns the error, otherwise returns false.
152 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
153 will be used to look up the package definition and agent restrictions will be
156 The following options are available: I<change>
158 I<change>, if set true, supresses any referral credit to a referring customer.
163 my( $self, %options ) = @_;
165 local $SIG{HUP} = 'IGNORE';
166 local $SIG{INT} = 'IGNORE';
167 local $SIG{QUIT} = 'IGNORE';
168 local $SIG{TERM} = 'IGNORE';
169 local $SIG{TSTP} = 'IGNORE';
170 local $SIG{PIPE} = 'IGNORE';
172 my $oldAutoCommit = $FS::UID::AutoCommit;
173 local $FS::UID::AutoCommit = 0;
176 my $error = $self->SUPER::insert;
178 $dbh->rollback if $oldAutoCommit;
182 #if ( $self->reg_code ) {
183 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
184 # $error = $reg_code->delete;
186 # $dbh->rollback if $oldAutoCommit;
191 my $conf = new FS::Conf;
192 my $cust_main = $self->cust_main;
193 my $part_pkg = $self->part_pkg;
194 if ( $conf->exists('referral_credit')
195 && $cust_main->referral_custnum
196 && ! $options{'change'}
197 && $part_pkg->freq !~ /^0\D?$/
200 my $referring_cust_main = $cust_main->referring_cust_main;
201 if ( $referring_cust_main->status ne 'cancelled' ) {
203 if ( $part_pkg->freq !~ /^\d+$/ ) {
204 warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum.
205 ' for package '. $self->pkgnum.
206 ' ( customer '. $self->custnum. ')'.
207 ' - One-time referral credits not (yet) available for '.
208 ' packages with '. $part_pkg->freq_pretty. ' frequency';
211 my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq );
213 $referring_cust_main->credit( $amount,
214 'Referral credit for '. $cust_main->name
217 $dbh->rollback if $oldAutoCommit;
218 return "Error crediting customer ". $cust_main->referral_custnum.
219 " for referral: $error";
227 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
234 This method now works but you probably shouldn't use it.
236 You don't want to delete billing items, because there would then be no record
237 the customer ever purchased the item. Instead, see the cancel method.
242 # return "Can't delete cust_pkg records!";
245 =item replace OLD_RECORD
247 Replaces the OLD_RECORD with this one in the database. If there is an error,
248 returns the error, otherwise returns false.
250 Currently, custnum, setup, bill, susp, expire, and cancel may be changed.
252 Changing pkgpart may have disasterous effects. See the order subroutine.
254 setup and bill are normally updated by calling the bill method of a customer
255 object (see L<FS::cust_main>).
257 suspend is normally updated by the suspend and unsuspend methods.
259 cancel is normally updated by the cancel method (and also the order subroutine
267 my( $new, $old, %options ) = @_;
269 # We absolutely have to have an old vs. new record to make this work.
270 if (!defined($old)) {
271 $old = qsearchs( 'cust_pkg', { 'pkgnum' => $new->pkgnum } );
273 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
274 return "Can't change otaker!" if $old->otaker ne $new->otaker;
277 #return "Can't change setup once it exists!"
278 # if $old->getfield('setup') &&
279 # $old->getfield('setup') != $new->getfield('setup');
281 #some logic for bill, susp, cancel?
283 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
285 local $SIG{HUP} = 'IGNORE';
286 local $SIG{INT} = 'IGNORE';
287 local $SIG{QUIT} = 'IGNORE';
288 local $SIG{TERM} = 'IGNORE';
289 local $SIG{TSTP} = 'IGNORE';
290 local $SIG{PIPE} = 'IGNORE';
292 my $oldAutoCommit = $FS::UID::AutoCommit;
293 local $FS::UID::AutoCommit = 0;
296 if ($options{'reason'} && $new->expire && $old->expire ne $new->expire) {
297 my $error = $new->insert_reason( 'reason' => $options{'reason'},
298 'date' => $new->expire,
301 dbh->rollback if $oldAutoCommit;
302 return "Error inserting cust_pkg_reason: $error";
306 #save off and freeze RADIUS attributes for any associated svc_acct records
308 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
310 #also check for specific exports?
311 # to avoid spurious modify export events
312 @svc_acct = map { $_->svc_x }
313 grep { $_->part_svc->svcdb eq 'svc_acct' }
316 $_->snapshot foreach @svc_acct;
320 my $error = $new->SUPER::replace($old);
322 $dbh->rollback if $oldAutoCommit;
326 #for prepaid packages,
327 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
328 foreach my $old_svc_acct ( @svc_acct ) {
329 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
330 my $s_error = $new_svc_acct->replace($old_svc_acct);
332 $dbh->rollback if $oldAutoCommit;
337 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
344 Checks all fields to make sure this is a valid billing item. If there is an
345 error, returns the error, otherwise returns false. Called by the insert and
354 $self->ut_numbern('pkgnum')
355 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
356 || $self->ut_numbern('pkgpart')
357 || $self->ut_numbern('setup')
358 || $self->ut_numbern('bill')
359 || $self->ut_numbern('susp')
360 || $self->ut_numbern('cancel')
362 return $error if $error;
364 if ( $self->reg_code ) {
366 unless ( grep { $self->pkgpart == $_->pkgpart }
367 map { $_->reg_code_pkg }
368 qsearchs( 'reg_code', { 'code' => $self->reg_code,
369 'agentnum' => $self->cust_main->agentnum })
371 return "Unknown registration code";
374 } elsif ( $self->promo_code ) {
377 qsearchs('part_pkg', {
378 'pkgpart' => $self->pkgpart,
379 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
381 return 'Unknown promotional code' unless $promo_part_pkg;
385 unless ( $disable_agentcheck ) {
387 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
388 my $pkgpart_href = $agent->pkgpart_hashref;
389 return "agent ". $agent->agentnum.
390 " can't purchase pkgpart ". $self->pkgpart
391 unless $pkgpart_href->{ $self->pkgpart };
394 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
395 return $error if $error;
399 $self->otaker(getotaker) unless $self->otaker;
400 $self->otaker =~ /^([\w\.\-]{0,16})$/ or return "Illegal otaker";
403 if ( $self->dbdef_table->column('manual_flag') ) {
404 $self->manual_flag('') if $self->manual_flag eq ' ';
405 $self->manual_flag =~ /^([01]?)$/
406 or return "Illegal manual_flag ". $self->manual_flag;
407 $self->manual_flag($1);
413 =item cancel [ OPTION => VALUE ... ]
415 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
416 in this package, then cancels the package itself (sets the cancel field to
419 Available options are: I<quiet>
421 I<quiet> can be set true to supress email cancellation notices.
423 If there is an error, returns the error, otherwise returns false.
428 my( $self, %options ) = @_;
431 local $SIG{HUP} = 'IGNORE';
432 local $SIG{INT} = 'IGNORE';
433 local $SIG{QUIT} = 'IGNORE';
434 local $SIG{TERM} = 'IGNORE';
435 local $SIG{TSTP} = 'IGNORE';
436 local $SIG{PIPE} = 'IGNORE';
438 my $oldAutoCommit = $FS::UID::AutoCommit;
439 local $FS::UID::AutoCommit = 0;
442 if ($options{'reason'}) {
443 $error = $self->insert_reason( 'reason' => $options{'reason'} );
445 dbh->rollback if $oldAutoCommit;
446 return "Error inserting cust_pkg_reason: $error";
451 foreach my $cust_svc (
454 sort { $a->[1] <=> $b->[1] }
455 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
456 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
459 my $error = $cust_svc->cancel;
462 $dbh->rollback if $oldAutoCommit;
463 return "Error cancelling cust_svc: $error";
467 # Add a credit for remaining service
468 my $remaining_value = $self->calc_remain();
469 if ( $remaining_value > 0 ) {
470 my $error = $self->cust_main->credit(
472 'Credit for unused time on '. $self->part_pkg->pkg,
475 $dbh->rollback if $oldAutoCommit;
476 return "Error crediting customer \$$remaining_value for unused time on".
477 $self->part_pkg->pkg. ": $error";
481 unless ( $self->getfield('cancel') ) {
482 my %hash = $self->hash;
483 $hash{'cancel'} = time;
484 my $new = new FS::cust_pkg ( \%hash );
485 $error = $new->replace($self);
487 $dbh->rollback if $oldAutoCommit;
492 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
494 my $conf = new FS::Conf;
495 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
496 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
497 my $conf = new FS::Conf;
498 my $error = send_email(
499 'from' => $conf->config('invoice_from'),
500 'to' => \@invoicing_list,
501 'subject' => $conf->config('cancelsubject'),
502 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
504 #should this do something on errors?
513 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
514 package, then suspends the package itself (sets the susp field to now).
516 If there is an error, returns the error, otherwise returns false.
521 my( $self, %options ) = @_;
524 local $SIG{HUP} = 'IGNORE';
525 local $SIG{INT} = 'IGNORE';
526 local $SIG{QUIT} = 'IGNORE';
527 local $SIG{TERM} = 'IGNORE';
528 local $SIG{TSTP} = 'IGNORE';
529 local $SIG{PIPE} = 'IGNORE';
531 my $oldAutoCommit = $FS::UID::AutoCommit;
532 local $FS::UID::AutoCommit = 0;
535 if ($options{'reason'}) {
536 $error = $self->insert_reason( 'reason' => $options{'reason'} );
538 dbh->rollback if $oldAutoCommit;
539 return "Error inserting cust_pkg_reason: $error";
543 foreach my $cust_svc (
544 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
546 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
548 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
549 $dbh->rollback if $oldAutoCommit;
550 return "Illegal svcdb value in part_svc!";
553 require "FS/$svcdb.pm";
555 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
557 $error = $svc->suspend;
559 $dbh->rollback if $oldAutoCommit;
566 unless ( $self->getfield('susp') ) {
567 my %hash = $self->hash;
568 $hash{'susp'} = time;
569 my $new = new FS::cust_pkg ( \%hash );
570 $error = $new->replace($self);
572 $dbh->rollback if $oldAutoCommit;
577 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
582 =item unsuspend [ OPTION => VALUE ... ]
584 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
585 package, then unsuspends the package itself (clears the susp field).
587 Available options are: I<adjust_next_bill>.
589 I<adjust_next_bill> can be set true to adjust the next bill date forward by
590 the amount of time the account was inactive. This was set true by default
591 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
592 explicitly requested. Price plans for which this makes sense (anniversary-date
593 based than prorate or subscription) could have an option to enable this
596 If there is an error, returns the error, otherwise returns false.
601 my( $self, %opt ) = @_;
604 local $SIG{HUP} = 'IGNORE';
605 local $SIG{INT} = 'IGNORE';
606 local $SIG{QUIT} = 'IGNORE';
607 local $SIG{TERM} = 'IGNORE';
608 local $SIG{TSTP} = 'IGNORE';
609 local $SIG{PIPE} = 'IGNORE';
611 my $oldAutoCommit = $FS::UID::AutoCommit;
612 local $FS::UID::AutoCommit = 0;
615 foreach my $cust_svc (
616 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
618 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
620 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
621 $dbh->rollback if $oldAutoCommit;
622 return "Illegal svcdb value in part_svc!";
625 require "FS/$svcdb.pm";
627 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
629 $error = $svc->unsuspend;
631 $dbh->rollback if $oldAutoCommit;
638 unless ( ! $self->getfield('susp') ) {
639 my %hash = $self->hash;
640 my $inactive = time - $hash{'susp'};
642 my $conf = new FS::Conf;
644 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
645 if ( $opt{'adjust_next_bill'}
646 || $conf->config('unsuspend-always_adjust_next_bill_date') )
647 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
650 my $new = new FS::cust_pkg ( \%hash );
651 $error = $new->replace($self);
653 $dbh->rollback if $oldAutoCommit;
658 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
665 Returns the last bill date, or if there is no last bill date, the setup date.
666 Useful for billing metered services.
672 if ( $self->dbdef_table->column('last_bill') ) {
673 return $self->setfield('last_bill', $_[0]) if @_;
674 return $self->getfield('last_bill') if $self->getfield('last_bill');
676 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
677 'edate' => $self->bill, } );
678 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
683 Returns the most recent FS::reason associated with the package.
689 my $cust_pkg_reason = qsearchs( {
690 'table' => 'cust_pkg_reason',
691 'hashref' => { 'pkgnum' => $self->pkgnum, },
692 'extra_sql'=> 'ORDER BY date DESC',
694 qsearchs ( 'reason', { 'reasonnum' => $cust_pkg_reason->reasonnum } )
700 Returns the definition for this billing item, as an FS::part_pkg object (see
707 #exists( $self->{'_pkgpart'} )
709 ? $self->{'_pkgpart'}
710 : qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
715 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
722 $self->part_pkg->calc_setup($self, @_);
727 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
734 $self->part_pkg->calc_recur($self, @_);
739 Calls the I<calc_remain> of the FS::part_pkg object associated with this
746 $self->part_pkg->calc_remain($self, @_);
751 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
758 $self->part_pkg->calc_cancel($self, @_);
763 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
769 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
772 =item cust_svc [ SVCPART ]
774 Returns the services for this package, as FS::cust_svc objects (see
775 L<FS::cust_svc>). If a svcpart is specified, return only the matching
784 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
785 'svcpart' => shift, } );
788 #if ( $self->{'_svcnum'} ) {
789 # values %{ $self->{'_svcnum'}->cache };
791 $self->_sort_cust_svc(
792 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
798 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
800 Returns historical services for this package created before END TIMESTAMP and
801 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
802 (see L<FS::h_cust_svc>).
809 $self->_sort_cust_svc(
810 [ qsearch( 'h_cust_svc',
811 { 'pkgnum' => $self->pkgnum, },
812 FS::h_cust_svc->sql_h_search(@_),
819 my( $self, $arrayref ) = @_;
822 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
824 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
825 'svcpart' => $_->svcpart } );
827 $pkg_svc ? $pkg_svc->primary_svc : '',
828 $pkg_svc ? $pkg_svc->quantity : 0,
835 =item num_cust_svc [ SVCPART ]
837 Returns the number of provisioned services for this package. If a svcpart is
838 specified, counts only the matching services.
844 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
845 $sql .= ' AND svcpart = ?' if @_;
846 my $sth = dbh->prepare($sql) or die dbh->errstr;
847 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
848 $sth->fetchrow_arrayref->[0];
851 =item available_part_svc
853 Returns a list of FS::part_svc objects representing services included in this
854 package but not yet provisioned. Each FS::part_svc object also has an extra
855 field, I<num_avail>, which specifies the number of available services.
859 sub available_part_svc {
861 grep { $_->num_avail > 0 }
863 my $part_svc = $_->part_svc;
864 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
865 $_->quantity - $self->num_cust_svc($_->svcpart);
868 $self->part_pkg->pkg_svc;
873 Returns a list of FS::part_svc objects representing provisioned and available
874 services included in this package. Each FS::part_svc object also has the
875 following extra fields:
879 =item num_cust_svc (count)
881 =item num_avail (quantity - count)
883 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
886 label -> ($cust_svc->label)[1]
895 #XXX some sort of sort order besides numeric by svcpart...
896 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
898 my $part_svc = $pkg_svc->part_svc;
899 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
900 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
901 $part_svc->{'Hash'}{'num_avail'} = $pkg_svc->quantity - $num_cust_svc;
902 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
904 } $self->part_pkg->pkg_svc;
907 push @part_svc, map {
909 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
910 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
911 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
912 $part_svc->{'Hash'}{'cust_pkg_svc'} = [ $self->cust_svc($part_svc->svcpart) ];
914 } $self->extra_part_svc;
922 Returns a list of FS::part_svc objects corresponding to services in this
923 package which are still provisioned but not (any longer) available in the
931 my $pkgnum = $self->pkgnum;
932 my $pkgpart = $self->pkgpart;
935 'table' => 'part_svc',
937 'extra_sql' => "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
938 WHERE pkg_svc.svcpart = part_svc.svcpart
939 AND pkg_svc.pkgpart = $pkgpart
942 AND 0 < ( SELECT count(*)
944 LEFT JOIN cust_pkg using ( pkgnum )
945 WHERE cust_svc.svcpart = part_svc.svcpart
953 Returns a short status string for this package, currently:
959 =item one-time charge
974 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
976 return 'cancelled' if $self->get('cancel');
977 return 'suspended' if $self->susp;
978 return 'not yet billed' unless $self->setup;
979 return 'one-time charge' if $freq =~ /^(0|$)/;
985 Class method that returns the list of possible status strings for pacakges
986 (see L<the status method|/status>). For example:
988 @statuses = FS::cust_pkg->statuses();
992 tie my %statuscolor, 'Tie::IxHash',
993 'not yet billed' => '000000',
994 'one-time charge' => '000000',
995 'active' => '00CC00',
996 'suspended' => 'FF9900',
997 'cancelled' => 'FF0000',
1001 my $self = shift; #could be class...
1002 grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1003 # mayble split btw one-time vs. recur
1009 Returns a hex triplet color string for this package's status.
1015 $statuscolor{$self->status};
1020 Returns a list of lists, calling the label method for all services
1021 (see L<FS::cust_svc>) of this billing item.
1027 map { [ $_->label ] } $self->cust_svc;
1030 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1032 Like the labels method, but returns historical information on services that
1033 were active as of END_TIMESTAMP and (optionally) not cancelled before
1036 Returns a list of lists, calling the label method for all (historical) services
1037 (see L<FS::h_cust_svc>) of this billing item.
1043 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1046 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1048 Like h_labels, except returns a simple flat list, and shortens long
1049 (currently >5) lists of identical services to one line that lists the service
1050 label and the number of individual services rather than individual items.
1054 sub h_labels_short {
1058 #tie %labels, 'Tie::IxHash';
1059 push @{ $labels{$_->[0]} }, $_->[1]
1060 foreach $self->h_labels(@_);
1062 foreach my $label ( keys %labels ) {
1063 my @values = @{ $labels{$label} };
1064 my $num = scalar(@values);
1066 push @labels, "$label ($num)";
1068 push @labels, map { "$label: $_" } @values;
1078 Returns the parent customer object (see L<FS::cust_main>).
1084 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1087 =item seconds_since TIMESTAMP
1089 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1090 package have been online since TIMESTAMP, according to the session monitor.
1092 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1093 L<Time::Local> and L<Date::Parse> for conversion functions.
1098 my($self, $since) = @_;
1101 foreach my $cust_svc (
1102 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1104 $seconds += $cust_svc->seconds_since($since);
1111 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1113 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1114 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1117 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1118 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1124 sub seconds_since_sqlradacct {
1125 my($self, $start, $end) = @_;
1129 foreach my $cust_svc (
1131 my $part_svc = $_->part_svc;
1132 $part_svc->svcdb eq 'svc_acct'
1133 && scalar($part_svc->part_export('sqlradius'));
1136 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1143 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1145 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1146 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1150 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1151 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1156 sub attribute_since_sqlradacct {
1157 my($self, $start, $end, $attrib) = @_;
1161 foreach my $cust_svc (
1163 my $part_svc = $_->part_svc;
1164 $part_svc->svcdb eq 'svc_acct'
1165 && scalar($part_svc->part_export('sqlradius'));
1168 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1175 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1177 Transfers as many services as possible from this package to another package.
1179 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1180 object. The destination package must already exist.
1182 Services are moved only if the destination allows services with the correct
1183 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1184 this option with caution! No provision is made for export differences
1185 between the old and new service definitions. Probably only should be used
1186 when your exports for all service definitions of a given svcdb are identical.
1187 (attempt a transfer without it first, to move all possible svcpart-matching
1190 Any services that can't be moved remain in the original package.
1192 Returns an error, if there is one; otherwise, returns the number of services
1193 that couldn't be moved.
1198 my ($self, $dest_pkgnum, %opt) = @_;
1204 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1205 $dest = $dest_pkgnum;
1206 $dest_pkgnum = $dest->pkgnum;
1208 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1211 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1213 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1214 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1217 foreach my $cust_svc ($dest->cust_svc) {
1218 $target{$cust_svc->svcpart}--;
1221 my %svcpart2svcparts = ();
1222 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1223 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
1224 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
1225 next if exists $svcpart2svcparts{$svcpart};
1226 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
1227 $svcpart2svcparts{$svcpart} = [
1229 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1231 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
1232 'svcpart' => $_ } );
1234 $pkg_svc ? $pkg_svc->primary_svc : '',
1235 $pkg_svc ? $pkg_svc->quantity : 0,
1239 grep { $_ != $svcpart }
1241 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
1243 warn "alternates for svcpart $svcpart: ".
1244 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
1249 foreach my $cust_svc ($self->cust_svc) {
1250 if($target{$cust_svc->svcpart} > 0) {
1251 $target{$cust_svc->svcpart}--;
1252 my $new = new FS::cust_svc {
1253 svcnum => $cust_svc->svcnum,
1254 svcpart => $cust_svc->svcpart,
1255 pkgnum => $dest_pkgnum,
1257 my $error = $new->replace($cust_svc);
1258 return $error if $error;
1259 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1261 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
1262 warn "alternates to consider: ".
1263 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
1265 my @alternate = grep {
1266 warn "considering alternate svcpart $_: ".
1267 "$target{$_} available in new package\n"
1270 } @{$svcpart2svcparts{$cust_svc->svcpart}};
1272 warn "alternate(s) found\n" if $DEBUG;
1273 my $change_svcpart = $alternate[0];
1274 $target{$change_svcpart}--;
1275 my $new = new FS::cust_svc {
1276 svcnum => $cust_svc->svcnum,
1277 svcpart => $change_svcpart,
1278 pkgnum => $dest_pkgnum,
1280 my $error = $new->replace($cust_svc);
1281 return $error if $error;
1294 This method is deprecated. See the I<depend_jobnum> option to the insert and
1295 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
1302 local $SIG{HUP} = 'IGNORE';
1303 local $SIG{INT} = 'IGNORE';
1304 local $SIG{QUIT} = 'IGNORE';
1305 local $SIG{TERM} = 'IGNORE';
1306 local $SIG{TSTP} = 'IGNORE';
1307 local $SIG{PIPE} = 'IGNORE';
1309 my $oldAutoCommit = $FS::UID::AutoCommit;
1310 local $FS::UID::AutoCommit = 0;
1313 foreach my $cust_svc ( $self->cust_svc ) {
1314 #false laziness w/svc_Common::insert
1315 my $svc_x = $cust_svc->svc_x;
1316 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
1317 my $error = $part_export->export_insert($svc_x);
1319 $dbh->rollback if $oldAutoCommit;
1325 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1332 =head1 CLASS METHODS
1338 Returns an SQL expression identifying recurring packages.
1342 sub recurring_sql { "
1343 '0' != ( select freq from part_pkg
1344 where cust_pkg.pkgpart = part_pkg.pkgpart )
1349 Returns an SQL expression identifying one-time packages.
1354 '0' = ( select freq from part_pkg
1355 where cust_pkg.pkgpart = part_pkg.pkgpart )
1360 Returns an SQL expression identifying active packages.
1365 ". $_[0]->recurring_sql(). "
1366 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1367 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1372 Returns an SQL expression identifying inactive packages (one-time packages
1373 that are otherwise unsuspended/uncancelled).
1377 sub inactive_sql { "
1378 ". $_[0]->onetime_sql(). "
1379 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1380 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
1386 Returns an SQL expression identifying suspended packages.
1390 sub suspended_sql { susp_sql(@_); }
1392 #$_[0]->recurring_sql(). ' AND '.
1394 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
1395 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
1402 Returns an SQL exprression identifying cancelled packages.
1406 sub cancelled_sql { cancel_sql(@_); }
1408 #$_[0]->recurring_sql(). ' AND '.
1409 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
1416 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] ]
1418 CUSTNUM is a customer (see L<FS::cust_main>)
1420 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
1421 L<FS::part_pkg>) to order for this customer. Duplicates are of course
1424 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
1425 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
1426 new billing items. An error is returned if this is not possible (see
1427 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
1430 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
1431 newly-created cust_pkg objects.
1436 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
1438 my $conf = new FS::Conf;
1440 # Transactionize this whole mess
1441 local $SIG{HUP} = 'IGNORE';
1442 local $SIG{INT} = 'IGNORE';
1443 local $SIG{QUIT} = 'IGNORE';
1444 local $SIG{TERM} = 'IGNORE';
1445 local $SIG{TSTP} = 'IGNORE';
1446 local $SIG{PIPE} = 'IGNORE';
1448 my $oldAutoCommit = $FS::UID::AutoCommit;
1449 local $FS::UID::AutoCommit = 0;
1453 my $cust_main = qsearchs('cust_main', { custnum => $custnum });
1454 return "Customer not found: $custnum" unless $cust_main;
1456 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
1459 my $change = scalar(@old_cust_pkg) != 0;
1462 if ( scalar(@old_cust_pkg) == 1 ) {
1463 #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill );
1464 $hash{'setup'} = time;
1467 # Create the new packages.
1468 foreach my $pkgpart (@$pkgparts) {
1469 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
1470 pkgpart => $pkgpart,
1473 $error = $cust_pkg->insert( 'change' => $change );
1475 $dbh->rollback if $oldAutoCommit;
1478 push @$return_cust_pkg, $cust_pkg;
1480 # $return_cust_pkg now contains refs to all of the newly
1483 # Transfer services and cancel old packages.
1484 foreach my $old_pkg (@old_cust_pkg) {
1486 foreach my $new_pkg (@$return_cust_pkg) {
1487 $error = $old_pkg->transfer($new_pkg);
1488 if ($error and $error == 0) {
1489 # $old_pkg->transfer failed.
1490 $dbh->rollback if $oldAutoCommit;
1495 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1496 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1497 foreach my $new_pkg (@$return_cust_pkg) {
1498 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
1499 if ($error and $error == 0) {
1500 # $old_pkg->transfer failed.
1501 $dbh->rollback if $oldAutoCommit;
1508 # Transfers were successful, but we went through all of the
1509 # new packages and still had services left on the old package.
1510 # We can't cancel the package under the circumstances, so abort.
1511 $dbh->rollback if $oldAutoCommit;
1512 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
1514 $error = $old_pkg->cancel( quiet=>1 );
1520 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1525 my ($self, %options) = @_;
1527 my $otaker = $FS::CurrentUser::CurrentUser->name;
1528 $otaker = $FS::CurrentUser::CurrentUser->username
1529 if (($otaker) eq "User, Legacy");
1531 my $cust_pkg_reason =
1532 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
1533 'reasonnum' => $options{'reason'},
1534 'otaker' => $otaker,
1535 'date' => $options{'date'}
1539 return $cust_pkg_reason->insert;
1542 =item set_usage USAGE_VALUE_HASHREF
1544 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
1545 to which they should be set (see L<FS::svc_acct>). Currently seconds,
1546 upbytes, downbytes, and totalbytes are appropriate keys.
1548 All svc_accts which are part of this package have their values reset.
1553 my ($self, $valueref) = @_;
1555 foreach my $cust_svc ($self->cust_svc){
1556 my $svc_x = $cust_svc->svc_x;
1557 $svc_x->set_usage($valueref)
1558 if $svc_x->can("set_usage");
1566 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
1568 In sub order, the @pkgparts array (passed by reference) is clobbered.
1570 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
1571 method to pass dates to the recur_prog expression, it should do so.
1573 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
1574 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
1575 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
1576 configuration values. Probably need a subroutine which decides what to do
1577 based on whether or not we've fetched the user yet, rather than a hash. See
1578 FS::UID and the TODO.
1580 Now that things are transactional should the check in the insert method be
1585 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
1586 L<FS::pkg_svc>, schema.html from the base documentation