4 use vars qw(@ISA $disable_agentcheck $DEBUG);
6 use Scalar::Util qw( blessed );
7 use List::Util qw(max);
10 use FS::UID qw( getotaker dbh );
11 use FS::Misc qw( send_email );
12 use FS::Record qw( qsearch qsearchs );
14 use FS::cust_main_Mixin;
18 use FS::cust_location;
20 use FS::cust_bill_pkg;
21 use FS::cust_pkg_detail;
26 use FS::cust_pkg_reason;
30 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
32 # because they load configuration by setting FS::UID::callback (see TODO)
38 # for sending cancel emails in sub cancel
41 @ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
45 $disable_agentcheck = 0;
49 my ( $hashref, $cache ) = @_;
50 #if ( $hashref->{'pkgpart'} ) {
51 if ( $hashref->{'pkg'} ) {
52 # #@{ $self->{'_pkgnum'} } = ();
53 # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
54 # $self->{'_pkgpart'} = $subcache;
55 # #push @{ $self->{'_pkgnum'} },
56 # FS::part_pkg->new_or_cached($hashref, $subcache);
57 $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
59 if ( exists $hashref->{'svcnum'} ) {
60 #@{ $self->{'_pkgnum'} } = ();
61 my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
62 $self->{'_svcnum'} = $subcache;
63 #push @{ $self->{'_pkgnum'} },
64 FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
70 FS::cust_pkg - Object methods for cust_pkg objects
76 $record = new FS::cust_pkg \%hash;
77 $record = new FS::cust_pkg { 'column' => 'value' };
79 $error = $record->insert;
81 $error = $new_record->replace($old_record);
83 $error = $record->delete;
85 $error = $record->check;
87 $error = $record->cancel;
89 $error = $record->suspend;
91 $error = $record->unsuspend;
93 $part_pkg = $record->part_pkg;
95 @labels = $record->labels;
97 $seconds = $record->seconds_since($timestamp);
99 $error = FS::cust_pkg::order( $custnum, \@pkgparts );
100 $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
104 An FS::cust_pkg object represents a customer billing item. FS::cust_pkg
105 inherits from FS::Record. The following fields are currently supported:
111 Primary key (assigned automatically for new billing items)
115 Customer (see L<FS::cust_main>)
119 Billing item definition (see L<FS::part_pkg>)
123 Optional link to package location (see L<FS::location>)
135 date (next bill date)
159 order taker (assigned automatically if null, see L<FS::UID>)
163 If this field is set to 1, disables the automatic
164 unsuspension of this package when using the B<unsuspendauto> config option.
168 If not set, defaults to 1
172 Date of change from previous package
182 =item change_locationnum
188 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
189 are specified as UNIX timestamps; see L<perlfunc/"time">. Also see
190 L<Time::Local> and L<Date::Parse> for conversion functions.
198 Create a new billing item. To add the item to the database, see L<"insert">.
202 sub table { 'cust_pkg'; }
203 sub cust_linked { $_[0]->cust_main_custnum; }
204 sub cust_unlinked_msg {
206 "WARNING: can't find cust_main.custnum ". $self->custnum.
207 ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
210 =item insert [ OPTION => VALUE ... ]
212 Adds this billing item to the database ("Orders" the item). If there is an
213 error, returns the error, otherwise returns false.
215 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
216 will be used to look up the package definition and agent restrictions will be
219 If the additional field I<refnum> is defined, an FS::pkg_referral record will
220 be created and inserted. Multiple FS::pkg_referral records can be created by
221 setting I<refnum> to an array reference of refnums or a hash reference with
222 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
223 record will be created corresponding to cust_main.refnum.
225 The following options are available:
231 If set true, supresses any referral credit to a referring customer.
235 cust_pkg_option records will be created
239 a ticket will be added to this customer with this subject
243 an optional queue name for ticket additions
250 my( $self, %options ) = @_;
252 local $SIG{HUP} = 'IGNORE';
253 local $SIG{INT} = 'IGNORE';
254 local $SIG{QUIT} = 'IGNORE';
255 local $SIG{TERM} = 'IGNORE';
256 local $SIG{TSTP} = 'IGNORE';
257 local $SIG{PIPE} = 'IGNORE';
259 my $oldAutoCommit = $FS::UID::AutoCommit;
260 local $FS::UID::AutoCommit = 0;
263 my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
265 $dbh->rollback if $oldAutoCommit;
269 $self->refnum($self->cust_main->refnum) unless $self->refnum;
270 $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
271 $self->process_m2m( 'link_table' => 'pkg_referral',
272 'target_table' => 'part_referral',
273 'params' => $self->refnum,
276 #if ( $self->reg_code ) {
277 # my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
278 # $error = $reg_code->delete;
280 # $dbh->rollback if $oldAutoCommit;
285 my $conf = new FS::Conf;
287 if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
289 use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
296 my $q = new RT::Queue($RT::SystemUser);
297 $q->Load($options{ticket_queue}) if $options{ticket_queue};
298 my $t = new RT::Ticket($RT::SystemUser);
299 my $mime = new MIME::Entity;
300 $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
301 $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
302 Subject => $options{ticket_subject},
305 $t->AddLink( Type => 'MemberOf',
306 Target => 'freeside://freeside/cust_main/'. $self->custnum,
310 if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
311 my $queue = new FS::queue {
312 'job' => 'FS::cust_main::queueable_print',
314 $error = $queue->insert(
315 'custnum' => $self->custnum,
316 'template' => 'welcome_letter',
320 warn "can't send welcome letter: $error";
325 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
332 This method now works but you probably shouldn't use it.
334 You don't want to delete billing items, because there would then be no record
335 the customer ever purchased the item. Instead, see the cancel method.
340 # return "Can't delete cust_pkg records!";
343 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
345 Replaces the OLD_RECORD with this one in the database. If there is an error,
346 returns the error, otherwise returns false.
348 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
350 Changing pkgpart may have disasterous effects. See the order subroutine.
352 setup and bill are normally updated by calling the bill method of a customer
353 object (see L<FS::cust_main>).
355 suspend is normally updated by the suspend and unsuspend methods.
357 cancel is normally updated by the cancel method (and also the order subroutine
360 Available options are:
366 can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
370 the access_user (see L<FS::access_user>) providing the reason
374 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
383 my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
388 ( ref($_[0]) eq 'HASH' )
392 #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
393 return "Can't change otaker!" if $old->otaker ne $new->otaker;
396 #return "Can't change setup once it exists!"
397 # if $old->getfield('setup') &&
398 # $old->getfield('setup') != $new->getfield('setup');
400 #some logic for bill, susp, cancel?
402 local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
404 local $SIG{HUP} = 'IGNORE';
405 local $SIG{INT} = 'IGNORE';
406 local $SIG{QUIT} = 'IGNORE';
407 local $SIG{TERM} = 'IGNORE';
408 local $SIG{TSTP} = 'IGNORE';
409 local $SIG{PIPE} = 'IGNORE';
411 my $oldAutoCommit = $FS::UID::AutoCommit;
412 local $FS::UID::AutoCommit = 0;
415 foreach my $method ( qw(adjourn expire) ) { # How many reasons?
416 if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
417 my $error = $new->insert_reason(
418 'reason' => $options->{'reason'},
419 'date' => $new->$method,
421 'reason_otaker' => $options->{'reason_otaker'},
424 dbh->rollback if $oldAutoCommit;
425 return "Error inserting cust_pkg_reason: $error";
430 #save off and freeze RADIUS attributes for any associated svc_acct records
432 if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
434 #also check for specific exports?
435 # to avoid spurious modify export events
436 @svc_acct = map { $_->svc_x }
437 grep { $_->part_svc->svcdb eq 'svc_acct' }
440 $_->snapshot foreach @svc_acct;
444 my $error = $new->SUPER::replace($old,
445 $options->{options} ? $options->{options} : ()
448 $dbh->rollback if $oldAutoCommit;
452 #for prepaid packages,
453 #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
454 foreach my $old_svc_acct ( @svc_acct ) {
455 my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
456 my $s_error = $new_svc_acct->replace($old_svc_acct);
458 $dbh->rollback if $oldAutoCommit;
463 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
470 Checks all fields to make sure this is a valid billing item. If there is an
471 error, returns the error, otherwise returns false. Called by the insert and
479 $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
482 $self->ut_numbern('pkgnum')
483 || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
484 || $self->ut_numbern('pkgpart')
485 || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
486 || $self->ut_numbern('start_date')
487 || $self->ut_numbern('setup')
488 || $self->ut_numbern('bill')
489 || $self->ut_numbern('susp')
490 || $self->ut_numbern('cancel')
491 || $self->ut_numbern('adjourn')
492 || $self->ut_numbern('expire')
494 return $error if $error;
496 if ( $self->reg_code ) {
498 unless ( grep { $self->pkgpart == $_->pkgpart }
499 map { $_->reg_code_pkg }
500 qsearchs( 'reg_code', { 'code' => $self->reg_code,
501 'agentnum' => $self->cust_main->agentnum })
503 return "Unknown registration code";
506 } elsif ( $self->promo_code ) {
509 qsearchs('part_pkg', {
510 'pkgpart' => $self->pkgpart,
511 'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
513 return 'Unknown promotional code' unless $promo_part_pkg;
517 unless ( $disable_agentcheck ) {
519 qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
520 return "agent ". $agent->agentnum. ':'. $agent->agent.
521 " can't purchase pkgpart ". $self->pkgpart
522 unless $agent->pkgpart_hashref->{ $self->pkgpart }
523 || $agent->agentnum == $self->part_pkg->agentnum;
526 $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
527 return $error if $error;
531 $self->otaker(getotaker) unless $self->otaker;
532 $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
535 if ( $self->dbdef_table->column('manual_flag') ) {
536 $self->manual_flag('') if $self->manual_flag eq ' ';
537 $self->manual_flag =~ /^([01]?)$/
538 or return "Illegal manual_flag ". $self->manual_flag;
539 $self->manual_flag($1);
545 =item cancel [ OPTION => VALUE ... ]
547 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
548 in this package, then cancels the package itself (sets the cancel field to
551 Available options are:
555 =item quiet - can be set true to supress email cancellation notices.
557 =item time - can be set to cancel the package based on a specific future or historical date. Using time ensures that the remaining amount is calculated correctly. Note however that this is an immediate cancel and just changes the date. You are PROBABLY looking to expire the account instead of using this.
559 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
561 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
563 =item nobill - can be set true to skip billing if it might otherwise be done.
567 If there is an error, returns the error, otherwise returns false.
572 my( $self, %options ) = @_;
575 my $conf = new FS::Conf;
577 warn "cust_pkg::cancel called with options".
578 join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
581 local $SIG{HUP} = 'IGNORE';
582 local $SIG{INT} = 'IGNORE';
583 local $SIG{QUIT} = 'IGNORE';
584 local $SIG{TERM} = 'IGNORE';
585 local $SIG{TSTP} = 'IGNORE';
586 local $SIG{PIPE} = 'IGNORE';
588 my $oldAutoCommit = $FS::UID::AutoCommit;
589 local $FS::UID::AutoCommit = 0;
592 my $old = $self->select_for_update;
594 if ( $old->get('cancel') || $self->get('cancel') ) {
595 dbh->rollback if $oldAutoCommit;
596 return ""; # no error
599 my $date = $options{date} if $options{date}; # expire/cancel later
600 $date = '' if ($date && $date <= time); # complain instead?
602 #race condition: usage could be ongoing until unprovisioned
603 #resolved by performing a change package instead (which unprovisions) and
605 if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
607 $self->cust_main->bill( pkg_list => [ $self ], cancel => 1 );
608 warn "Error billing during cancel, custnum ".
609 #$self->cust_main->custnum. ": $error"
615 my $cancel_time = $options{'time'} || time;
617 if ( $options{'reason'} ) {
618 $error = $self->insert_reason( 'reason' => $options{'reason'},
619 'action' => $date ? 'expire' : 'cancel',
620 'date' => $date ? $date : $cancel_time,
621 'reason_otaker' => $options{'reason_otaker'},
624 dbh->rollback if $oldAutoCommit;
625 return "Error inserting cust_pkg_reason: $error";
631 foreach my $cust_svc (
634 sort { $a->[1] <=> $b->[1] }
635 map { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
636 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
639 my $error = $cust_svc->cancel;
642 $dbh->rollback if $oldAutoCommit;
643 return "Error cancelling cust_svc: $error";
647 # Add a credit for remaining service
648 my $remaining_value = $self->calc_remain(time=>$cancel_time);
649 if ( $remaining_value > 0 && !$options{'no_credit'} ) {
650 my $error = $self->cust_main->credit(
652 'Credit for unused time on '. $self->part_pkg->pkg,
653 'reason_type' => $conf->config('cancel_credit_type'),
656 $dbh->rollback if $oldAutoCommit;
657 return "Error crediting customer \$$remaining_value for unused time on".
658 $self->part_pkg->pkg. ": $error";
663 my %hash = $self->hash;
664 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
665 my $new = new FS::cust_pkg ( \%hash );
666 $error = $new->replace( $self, options => { $self->options } );
668 $dbh->rollback if $oldAutoCommit;
672 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
673 return '' if $date; #no errors
675 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
676 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
677 my $error = send_email(
678 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
679 'to' => \@invoicing_list,
680 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
681 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
683 #should this do something on errors?
690 =item cancel_if_expired [ NOW_TIMESTAMP ]
692 Cancels this package if its expire date has been reached.
696 sub cancel_if_expired {
698 my $time = shift || time;
699 return '' unless $self->expire && $self->expire <= $time;
700 my $error = $self->cancel;
702 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
703 $self->custnum. ": $error";
710 Cancels any pending expiration (sets the expire field to null).
712 If there is an error, returns the error, otherwise returns false.
717 my( $self, %options ) = @_;
720 local $SIG{HUP} = 'IGNORE';
721 local $SIG{INT} = 'IGNORE';
722 local $SIG{QUIT} = 'IGNORE';
723 local $SIG{TERM} = 'IGNORE';
724 local $SIG{TSTP} = 'IGNORE';
725 local $SIG{PIPE} = 'IGNORE';
727 my $oldAutoCommit = $FS::UID::AutoCommit;
728 local $FS::UID::AutoCommit = 0;
731 my $old = $self->select_for_update;
733 my $pkgnum = $old->pkgnum;
734 if ( $old->get('cancel') || $self->get('cancel') ) {
735 dbh->rollback if $oldAutoCommit;
736 return "Can't unexpire cancelled package $pkgnum";
737 # or at least it's pointless
740 unless ( $old->get('expire') && $self->get('expire') ) {
741 dbh->rollback if $oldAutoCommit;
742 return ""; # no error
745 my %hash = $self->hash;
746 $hash{'expire'} = '';
747 my $new = new FS::cust_pkg ( \%hash );
748 $error = $new->replace( $self, options => { $self->options } );
750 $dbh->rollback if $oldAutoCommit;
754 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
760 =item suspend [ OPTION => VALUE ... ]
762 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
763 package, then suspends the package itself (sets the susp field to now).
765 Available options are:
769 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
771 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
775 If there is an error, returns the error, otherwise returns false.
780 my( $self, %options ) = @_;
783 local $SIG{HUP} = 'IGNORE';
784 local $SIG{INT} = 'IGNORE';
785 local $SIG{QUIT} = 'IGNORE';
786 local $SIG{TERM} = 'IGNORE';
787 local $SIG{TSTP} = 'IGNORE';
788 local $SIG{PIPE} = 'IGNORE';
790 my $oldAutoCommit = $FS::UID::AutoCommit;
791 local $FS::UID::AutoCommit = 0;
794 my $old = $self->select_for_update;
796 my $pkgnum = $old->pkgnum;
797 if ( $old->get('cancel') || $self->get('cancel') ) {
798 dbh->rollback if $oldAutoCommit;
799 return "Can't suspend cancelled package $pkgnum";
802 if ( $old->get('susp') || $self->get('susp') ) {
803 dbh->rollback if $oldAutoCommit;
804 return ""; # no error # complain on adjourn?
807 my $date = $options{date} if $options{date}; # adjourn/suspend later
808 $date = '' if ($date && $date <= time); # complain instead?
810 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
811 dbh->rollback if $oldAutoCommit;
812 return "Package $pkgnum expires before it would be suspended.";
815 my $suspend_time = $options{'time'} || time;
817 if ( $options{'reason'} ) {
818 $error = $self->insert_reason( 'reason' => $options{'reason'},
819 'action' => $date ? 'adjourn' : 'suspend',
820 'date' => $date ? $date : $suspend_time,
821 'reason_otaker' => $options{'reason_otaker'},
824 dbh->rollback if $oldAutoCommit;
825 return "Error inserting cust_pkg_reason: $error";
833 foreach my $cust_svc (
834 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
836 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
838 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
839 $dbh->rollback if $oldAutoCommit;
840 return "Illegal svcdb value in part_svc!";
843 require "FS/$svcdb.pm";
845 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
847 $error = $svc->suspend;
849 $dbh->rollback if $oldAutoCommit;
852 my( $label, $value ) = $cust_svc->label;
853 push @labels, "$label: $value";
857 my $conf = new FS::Conf;
858 if ( $conf->config('suspend_email_admin') ) {
860 my $error = send_email(
861 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
862 #invoice_from ??? well as good as any
863 'to' => $conf->config('suspend_email_admin'),
864 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
866 "This is an automatic message from your Freeside installation\n",
867 "informing you that the following customer package has been suspended:\n",
869 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
870 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
871 ( map { "Service : $_\n" } @labels ),
876 warn "WARNING: can't send suspension admin email (suspending anyway): ".
884 my %hash = $self->hash;
886 $hash{'adjourn'} = $date;
888 $hash{'susp'} = $suspend_time;
890 my $new = new FS::cust_pkg ( \%hash );
891 $error = $new->replace( $self, options => { $self->options } );
893 $dbh->rollback if $oldAutoCommit;
897 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
902 =item unsuspend [ OPTION => VALUE ... ]
904 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
905 package, then unsuspends the package itself (clears the susp field and the
906 adjourn field if it is in the past).
908 Available options are:
912 =item adjust_next_bill
914 Can be set true to adjust the next bill date forward by
915 the amount of time the account was inactive. This was set true by default
916 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
917 explicitly requested. Price plans for which this makes sense (anniversary-date
918 based than prorate or subscription) could have an option to enable this
923 If there is an error, returns the error, otherwise returns false.
928 my( $self, %opt ) = @_;
931 local $SIG{HUP} = 'IGNORE';
932 local $SIG{INT} = 'IGNORE';
933 local $SIG{QUIT} = 'IGNORE';
934 local $SIG{TERM} = 'IGNORE';
935 local $SIG{TSTP} = 'IGNORE';
936 local $SIG{PIPE} = 'IGNORE';
938 my $oldAutoCommit = $FS::UID::AutoCommit;
939 local $FS::UID::AutoCommit = 0;
942 my $old = $self->select_for_update;
944 my $pkgnum = $old->pkgnum;
945 if ( $old->get('cancel') || $self->get('cancel') ) {
946 dbh->rollback if $oldAutoCommit;
947 return "Can't unsuspend cancelled package $pkgnum";
950 unless ( $old->get('susp') && $self->get('susp') ) {
951 dbh->rollback if $oldAutoCommit;
952 return ""; # no error # complain instead?
955 foreach my $cust_svc (
956 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
958 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
960 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
961 $dbh->rollback if $oldAutoCommit;
962 return "Illegal svcdb value in part_svc!";
965 require "FS/$svcdb.pm";
967 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
969 $error = $svc->unsuspend;
971 $dbh->rollback if $oldAutoCommit;
978 my %hash = $self->hash;
979 my $inactive = time - $hash{'susp'};
981 my $conf = new FS::Conf;
983 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
984 if ( $opt{'adjust_next_bill'}
985 || $conf->exists('unsuspend-always_adjust_next_bill_date') )
986 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
989 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
990 my $new = new FS::cust_pkg ( \%hash );
991 $error = $new->replace( $self, options => { $self->options } );
993 $dbh->rollback if $oldAutoCommit;
997 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1004 Cancels any pending suspension (sets the adjourn field to null).
1006 If there is an error, returns the error, otherwise returns false.
1011 my( $self, %options ) = @_;
1014 local $SIG{HUP} = 'IGNORE';
1015 local $SIG{INT} = 'IGNORE';
1016 local $SIG{QUIT} = 'IGNORE';
1017 local $SIG{TERM} = 'IGNORE';
1018 local $SIG{TSTP} = 'IGNORE';
1019 local $SIG{PIPE} = 'IGNORE';
1021 my $oldAutoCommit = $FS::UID::AutoCommit;
1022 local $FS::UID::AutoCommit = 0;
1025 my $old = $self->select_for_update;
1027 my $pkgnum = $old->pkgnum;
1028 if ( $old->get('cancel') || $self->get('cancel') ) {
1029 dbh->rollback if $oldAutoCommit;
1030 return "Can't unadjourn cancelled package $pkgnum";
1031 # or at least it's pointless
1034 if ( $old->get('susp') || $self->get('susp') ) {
1035 dbh->rollback if $oldAutoCommit;
1036 return "Can't unadjourn suspended package $pkgnum";
1037 # perhaps this is arbitrary
1040 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1041 dbh->rollback if $oldAutoCommit;
1042 return ""; # no error
1045 my %hash = $self->hash;
1046 $hash{'adjourn'} = '';
1047 my $new = new FS::cust_pkg ( \%hash );
1048 $error = $new->replace( $self, options => { $self->options } );
1050 $dbh->rollback if $oldAutoCommit;
1054 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1061 =item change HASHREF | OPTION => VALUE ...
1063 Changes this package: cancels it and creates a new one, with a different
1064 pkgpart or locationnum or both. All services are transferred to the new
1065 package (no change will be made if this is not possible).
1067 Options may be passed as a list of key/value pairs or as a hash reference.
1074 New locationnum, to change the location for this package.
1078 New FS::cust_location object, to create a new location and assign it
1083 New pkgpart (see L<FS::part_pkg>).
1087 New refnum (see L<FS::part_referral>).
1091 At least one option must be specified (otherwise, what's the point?)
1093 Returns either the new FS::cust_pkg object or a scalar error.
1097 my $err_or_new_cust_pkg = $old_cust_pkg->change
1101 #some false laziness w/order
1104 my $opt = ref($_[0]) ? shift : { @_ };
1106 # my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1109 my $conf = new FS::Conf;
1111 # Transactionize this whole mess
1112 local $SIG{HUP} = 'IGNORE';
1113 local $SIG{INT} = 'IGNORE';
1114 local $SIG{QUIT} = 'IGNORE';
1115 local $SIG{TERM} = 'IGNORE';
1116 local $SIG{TSTP} = 'IGNORE';
1117 local $SIG{PIPE} = 'IGNORE';
1119 my $oldAutoCommit = $FS::UID::AutoCommit;
1120 local $FS::UID::AutoCommit = 0;
1129 #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1131 #$hash{$_} = $self->$_() foreach qw( setup );
1133 $hash{'setup'} = $time if $self->setup;
1135 $hash{'change_date'} = $time;
1136 $hash{"change_$_"} = $self->$_()
1137 foreach qw( pkgnum pkgpart locationnum );
1139 if ( $opt->{'cust_location'} &&
1140 ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1141 $error = $opt->{'cust_location'}->insert;
1143 $dbh->rollback if $oldAutoCommit;
1144 return "inserting cust_location (transaction rolled back): $error";
1146 $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1149 # Create the new package.
1150 my $cust_pkg = new FS::cust_pkg {
1151 custnum => $self->custnum,
1152 pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ),
1153 refnum => ( $opt->{'refnum'} || $self->refnum ),
1154 locationnum => ( $opt->{'locationnum'} || $self->locationnum ),
1158 $error = $cust_pkg->insert( 'change' => 1 );
1160 $dbh->rollback if $oldAutoCommit;
1164 # Transfer services and cancel old package.
1166 $error = $self->transfer($cust_pkg);
1167 if ($error and $error == 0) {
1168 # $old_pkg->transfer failed.
1169 $dbh->rollback if $oldAutoCommit;
1173 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1174 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1175 $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1176 if ($error and $error == 0) {
1177 # $old_pkg->transfer failed.
1178 $dbh->rollback if $oldAutoCommit;
1184 # Transfers were successful, but we still had services left on the old
1185 # package. We can't change the package under this circumstances, so abort.
1186 $dbh->rollback if $oldAutoCommit;
1187 return "Unable to transfer all services from package ". $self->pkgnum;
1190 #reset usage if changing pkgpart
1191 if ($self->pkgpart != $cust_pkg->pkgpart) {
1192 my $part_pkg = $cust_pkg->part_pkg;
1193 $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1197 if $part_pkg->can('reset_usage');
1200 $dbh->rollback if $oldAutoCommit;
1201 return "Error setting usage values: $error";
1205 #Good to go, cancel old package.
1206 $error = $self->cancel( quiet=>1 );
1212 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1219 Returns the last bill date, or if there is no last bill date, the setup date.
1220 Useful for billing metered services.
1226 return $self->setfield('last_bill', $_[0]) if @_;
1227 return $self->getfield('last_bill') if $self->getfield('last_bill');
1228 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1229 'edate' => $self->bill, } );
1230 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1233 =item last_cust_pkg_reason ACTION
1235 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1236 Returns false if there is no reason or the package is not currenly ACTION'd
1237 ACTION is one of adjourn, susp, cancel, or expire.
1241 sub last_cust_pkg_reason {
1242 my ( $self, $action ) = ( shift, shift );
1243 my $date = $self->get($action);
1245 'table' => 'cust_pkg_reason',
1246 'hashref' => { 'pkgnum' => $self->pkgnum,
1247 'action' => substr(uc($action), 0, 1),
1250 'order_by' => 'ORDER BY num DESC LIMIT 1',
1254 =item last_reason ACTION
1256 Returns the most recent ACTION FS::reason associated with the package.
1257 Returns false if there is no reason or the package is not currenly ACTION'd
1258 ACTION is one of adjourn, susp, cancel, or expire.
1263 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1264 $cust_pkg_reason->reason
1265 if $cust_pkg_reason;
1270 Returns the definition for this billing item, as an FS::part_pkg object (see
1277 return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1278 cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1279 qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1284 Returns the cancelled package this package was changed from, if any.
1290 return '' unless $self->change_pkgnum;
1291 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1296 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1303 $self->part_pkg->calc_setup($self, @_);
1308 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1315 $self->part_pkg->calc_recur($self, @_);
1320 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1327 $self->part_pkg->calc_remain($self, @_);
1332 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1339 $self->part_pkg->calc_cancel($self, @_);
1344 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1350 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1353 =item cust_pkg_detail [ DETAILTYPE ]
1355 Returns any customer package details for this package (see
1356 L<FS::cust_pkg_detail>).
1358 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1362 sub cust_pkg_detail {
1364 my %hash = ( 'pkgnum' => $self->pkgnum );
1365 $hash{detailtype} = shift if @_;
1367 'table' => 'cust_pkg_detail',
1368 'hashref' => \%hash,
1369 'order_by' => 'ORDER BY weight, pkgdetailnum',
1373 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1375 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1377 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1379 If there is an error, returns the error, otherwise returns false.
1383 sub set_cust_pkg_detail {
1384 my( $self, $detailtype, @details ) = @_;
1386 local $SIG{HUP} = 'IGNORE';
1387 local $SIG{INT} = 'IGNORE';
1388 local $SIG{QUIT} = 'IGNORE';
1389 local $SIG{TERM} = 'IGNORE';
1390 local $SIG{TSTP} = 'IGNORE';
1391 local $SIG{PIPE} = 'IGNORE';
1393 my $oldAutoCommit = $FS::UID::AutoCommit;
1394 local $FS::UID::AutoCommit = 0;
1397 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1398 my $error = $current->delete;
1400 $dbh->rollback if $oldAutoCommit;
1401 return "error removing old detail: $error";
1405 foreach my $detail ( @details ) {
1406 my $cust_pkg_detail = new FS::cust_pkg_detail {
1407 'pkgnum' => $self->pkgnum,
1408 'detailtype' => $detailtype,
1409 'detail' => $detail,
1411 my $error = $cust_pkg_detail->insert;
1413 $dbh->rollback if $oldAutoCommit;
1414 return "error adding new detail: $error";
1419 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1426 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1430 #false laziness w/cust_bill.pm
1434 'table' => 'cust_event',
1435 'addl_from' => 'JOIN part_event USING ( eventpart )',
1436 'hashref' => { 'tablenum' => $self->pkgnum },
1437 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1441 =item num_cust_event
1443 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1447 #false laziness w/cust_bill.pm
1448 sub num_cust_event {
1451 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1452 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1453 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1454 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1455 $sth->fetchrow_arrayref->[0];
1458 =item cust_svc [ SVCPART ]
1460 Returns the services for this package, as FS::cust_svc objects (see
1461 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1469 return () unless $self->num_cust_svc(@_);
1472 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1473 'svcpart' => shift, } );
1476 cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
1478 #if ( $self->{'_svcnum'} ) {
1479 # values %{ $self->{'_svcnum'}->cache };
1481 $self->_sort_cust_svc(
1482 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1488 =item overlimit [ SVCPART ]
1490 Returns the services for this package which have exceeded their
1491 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1492 is specified, return only the matching services.
1498 return () unless $self->num_cust_svc(@_);
1499 grep { $_->overlimit } $self->cust_svc(@_);
1502 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1504 Returns historical services for this package created before END TIMESTAMP and
1505 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1506 (see L<FS::h_cust_svc>).
1513 $self->_sort_cust_svc(
1514 [ qsearch( 'h_cust_svc',
1515 { 'pkgnum' => $self->pkgnum, },
1516 FS::h_cust_svc->sql_h_search(@_),
1522 sub _sort_cust_svc {
1523 my( $self, $arrayref ) = @_;
1526 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1528 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1529 'svcpart' => $_->svcpart } );
1531 $pkg_svc ? $pkg_svc->primary_svc : '',
1532 $pkg_svc ? $pkg_svc->quantity : 0,
1539 =item num_cust_svc [ SVCPART ]
1541 Returns the number of provisioned services for this package. If a svcpart is
1542 specified, counts only the matching services.
1549 return $self->{'_num_cust_svc'}
1551 && exists($self->{'_num_cust_svc'})
1552 && $self->{'_num_cust_svc'} =~ /\d/;
1554 cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
1557 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1558 $sql .= ' AND svcpart = ?' if @_;
1560 my $sth = dbh->prepare($sql) or die dbh->errstr;
1561 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1562 $sth->fetchrow_arrayref->[0];
1565 =item available_part_svc
1567 Returns a list of FS::part_svc objects representing services included in this
1568 package but not yet provisioned. Each FS::part_svc object also has an extra
1569 field, I<num_avail>, which specifies the number of available services.
1573 sub available_part_svc {
1575 grep { $_->num_avail > 0 }
1577 my $part_svc = $_->part_svc;
1578 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1579 $_->quantity - $self->num_cust_svc($_->svcpart);
1582 $self->part_pkg->pkg_svc;
1587 Returns a list of FS::part_svc objects representing provisioned and available
1588 services included in this package. Each FS::part_svc object also has the
1589 following extra fields:
1593 =item num_cust_svc (count)
1595 =item num_avail (quantity - count)
1597 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1600 label -> ($cust_svc->label)[1]
1609 #XXX some sort of sort order besides numeric by svcpart...
1610 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1612 my $part_svc = $pkg_svc->part_svc;
1613 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1614 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1615 $part_svc->{'Hash'}{'num_avail'} =
1616 max( 0, $pkg_svc->quantity - $num_cust_svc );
1617 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1618 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1620 } $self->part_pkg->pkg_svc;
1623 push @part_svc, map {
1625 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1626 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1627 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1628 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1629 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1631 } $self->extra_part_svc;
1637 =item extra_part_svc
1639 Returns a list of FS::part_svc objects corresponding to services in this
1640 package which are still provisioned but not (any longer) available in the
1645 sub extra_part_svc {
1648 my $pkgnum = $self->pkgnum;
1649 my $pkgpart = $self->pkgpart;
1652 # 'table' => 'part_svc',
1655 # "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1656 # WHERE pkg_svc.svcpart = part_svc.svcpart
1657 # AND pkg_svc.pkgpart = ?
1660 # AND 0 < ( SELECT COUNT(*) FROM cust_svc
1661 # LEFT JOIN cust_pkg USING ( pkgnum )
1662 # WHERE cust_svc.svcpart = part_svc.svcpart
1665 # 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1668 #seems to benchmark slightly faster...
1670 'select' => 'DISTINCT ON (svcpart) part_svc.*',
1671 'table' => 'part_svc',
1673 'LEFT JOIN pkg_svc ON ( pkg_svc.svcpart = part_svc.svcpart
1674 AND pkg_svc.pkgpart = ?
1677 LEFT JOIN cust_svc ON ( cust_svc.svcpart = part_svc.svcpart )
1678 LEFT JOIN cust_pkg USING ( pkgnum )
1681 'extra_sql' => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1682 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1688 Returns a short status string for this package, currently:
1692 =item not yet billed
1694 =item one-time charge
1709 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1711 return 'cancelled' if $self->get('cancel');
1712 return 'suspended' if $self->susp;
1713 return 'not yet billed' unless $self->setup;
1714 return 'one-time charge' if $freq =~ /^(0|$)/;
1720 Class method that returns the list of possible status strings for packages
1721 (see L<the status method|/status>). For example:
1723 @statuses = FS::cust_pkg->statuses();
1727 tie my %statuscolor, 'Tie::IxHash',
1728 'not yet billed' => '000000',
1729 'one-time charge' => '000000',
1730 'active' => '00CC00',
1731 'suspended' => 'FF9900',
1732 'cancelled' => 'FF0000',
1736 my $self = shift; #could be class...
1737 #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1738 # # mayble split btw one-time vs. recur
1744 Returns a hex triplet color string for this package's status.
1750 $statuscolor{$self->status};
1755 Returns a list of lists, calling the label method for all services
1756 (see L<FS::cust_svc>) of this billing item.
1762 map { [ $_->label ] } $self->cust_svc;
1765 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1767 Like the labels method, but returns historical information on services that
1768 were active as of END_TIMESTAMP and (optionally) not cancelled before
1771 Returns a list of lists, calling the label method for all (historical) services
1772 (see L<FS::h_cust_svc>) of this billing item.
1778 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1781 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1783 Like h_labels, except returns a simple flat list, and shortens long
1784 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1785 identical services to one line that lists the service label and the number of
1786 individual services rather than individual items.
1790 sub h_labels_short {
1793 my $conf = new FS::Conf;
1794 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1797 #tie %labels, 'Tie::IxHash';
1798 push @{ $labels{$_->[0]} }, $_->[1]
1799 foreach $self->h_labels(@_);
1801 foreach my $label ( keys %labels ) {
1803 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1804 my $num = scalar(@values);
1805 if ( $num > $max_same_services ) {
1806 push @labels, "$label ($num)";
1808 push @labels, map { "$label: $_" } @values;
1818 Returns the parent customer object (see L<FS::cust_main>).
1824 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1829 Returns the location object, if any (see L<FS::cust_location>).
1835 return '' unless $self->locationnum;
1836 qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
1839 =item cust_location_or_main
1841 If this package is associated with a location, returns the locaiton (see
1842 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1846 sub cust_location_or_main {
1848 $self->cust_location || $self->cust_main;
1851 =item seconds_since TIMESTAMP
1853 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1854 package have been online since TIMESTAMP, according to the session monitor.
1856 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1857 L<Time::Local> and L<Date::Parse> for conversion functions.
1862 my($self, $since) = @_;
1865 foreach my $cust_svc (
1866 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1868 $seconds += $cust_svc->seconds_since($since);
1875 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1877 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1878 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1881 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1882 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1888 sub seconds_since_sqlradacct {
1889 my($self, $start, $end) = @_;
1893 foreach my $cust_svc (
1895 my $part_svc = $_->part_svc;
1896 $part_svc->svcdb eq 'svc_acct'
1897 && scalar($part_svc->part_export('sqlradius'));
1900 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1907 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1909 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1910 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1914 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1915 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1920 sub attribute_since_sqlradacct {
1921 my($self, $start, $end, $attrib) = @_;
1925 foreach my $cust_svc (
1927 my $part_svc = $_->part_svc;
1928 $part_svc->svcdb eq 'svc_acct'
1929 && scalar($part_svc->part_export('sqlradius'));
1932 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1944 my( $self, $value ) = @_;
1945 if ( defined($value) ) {
1946 $self->setfield('quantity', $value);
1948 $self->getfield('quantity') || 1;
1951 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1953 Transfers as many services as possible from this package to another package.
1955 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1956 object. The destination package must already exist.
1958 Services are moved only if the destination allows services with the correct
1959 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1960 this option with caution! No provision is made for export differences
1961 between the old and new service definitions. Probably only should be used
1962 when your exports for all service definitions of a given svcdb are identical.
1963 (attempt a transfer without it first, to move all possible svcpart-matching
1966 Any services that can't be moved remain in the original package.
1968 Returns an error, if there is one; otherwise, returns the number of services
1969 that couldn't be moved.
1974 my ($self, $dest_pkgnum, %opt) = @_;
1980 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1981 $dest = $dest_pkgnum;
1982 $dest_pkgnum = $dest->pkgnum;
1984 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1987 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1989 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1990 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1993 foreach my $cust_svc ($dest->cust_svc) {
1994 $target{$cust_svc->svcpart}--;
1997 my %svcpart2svcparts = ();
1998 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
1999 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2000 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2001 next if exists $svcpart2svcparts{$svcpart};
2002 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2003 $svcpart2svcparts{$svcpart} = [
2005 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
2007 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2008 'svcpart' => $_ } );
2010 $pkg_svc ? $pkg_svc->primary_svc : '',
2011 $pkg_svc ? $pkg_svc->quantity : 0,
2015 grep { $_ != $svcpart }
2017 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2019 warn "alternates for svcpart $svcpart: ".
2020 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2025 foreach my $cust_svc ($self->cust_svc) {
2026 if($target{$cust_svc->svcpart} > 0) {
2027 $target{$cust_svc->svcpart}--;
2028 my $new = new FS::cust_svc { $cust_svc->hash };
2029 $new->pkgnum($dest_pkgnum);
2030 my $error = $new->replace($cust_svc);
2031 return $error if $error;
2032 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2034 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2035 warn "alternates to consider: ".
2036 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2038 my @alternate = grep {
2039 warn "considering alternate svcpart $_: ".
2040 "$target{$_} available in new package\n"
2043 } @{$svcpart2svcparts{$cust_svc->svcpart}};
2045 warn "alternate(s) found\n" if $DEBUG;
2046 my $change_svcpart = $alternate[0];
2047 $target{$change_svcpart}--;
2048 my $new = new FS::cust_svc { $cust_svc->hash };
2049 $new->svcpart($change_svcpart);
2050 $new->pkgnum($dest_pkgnum);
2051 my $error = $new->replace($cust_svc);
2052 return $error if $error;
2065 This method is deprecated. See the I<depend_jobnum> option to the insert and
2066 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2073 local $SIG{HUP} = 'IGNORE';
2074 local $SIG{INT} = 'IGNORE';
2075 local $SIG{QUIT} = 'IGNORE';
2076 local $SIG{TERM} = 'IGNORE';
2077 local $SIG{TSTP} = 'IGNORE';
2078 local $SIG{PIPE} = 'IGNORE';
2080 my $oldAutoCommit = $FS::UID::AutoCommit;
2081 local $FS::UID::AutoCommit = 0;
2084 foreach my $cust_svc ( $self->cust_svc ) {
2085 #false laziness w/svc_Common::insert
2086 my $svc_x = $cust_svc->svc_x;
2087 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2088 my $error = $part_export->export_insert($svc_x);
2090 $dbh->rollback if $oldAutoCommit;
2096 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2103 =head1 CLASS METHODS
2109 Returns an SQL expression identifying recurring packages.
2113 sub recurring_sql { "
2114 '0' != ( select freq from part_pkg
2115 where cust_pkg.pkgpart = part_pkg.pkgpart )
2120 Returns an SQL expression identifying one-time packages.
2125 '0' = ( select freq from part_pkg
2126 where cust_pkg.pkgpart = part_pkg.pkgpart )
2131 Returns an SQL expression identifying active packages.
2136 ". $_[0]->recurring_sql(). "
2137 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2138 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2141 =item not_yet_billed_sql
2143 Returns an SQL expression identifying packages which have not yet been billed.
2147 sub not_yet_billed_sql { "
2148 ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 )
2149 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2150 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2155 Returns an SQL expression identifying inactive packages (one-time packages
2156 that are otherwise unsuspended/uncancelled).
2160 sub inactive_sql { "
2161 ". $_[0]->onetime_sql(). "
2162 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2163 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2164 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2170 Returns an SQL expression identifying suspended packages.
2174 sub suspended_sql { susp_sql(@_); }
2176 #$_[0]->recurring_sql(). ' AND '.
2178 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2179 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
2186 Returns an SQL exprression identifying cancelled packages.
2190 sub cancelled_sql { cancel_sql(@_); }
2192 #$_[0]->recurring_sql(). ' AND '.
2193 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2196 =item search_sql HASHREF
2200 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2201 Valid parameters are
2209 active, inactive, suspended, cancel (or cancelled)
2213 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2217 boolean selects custom packages
2227 arrayref of beginning and ending epoch date
2231 arrayref of beginning and ending epoch date
2235 arrayref of beginning and ending epoch date
2239 arrayref of beginning and ending epoch date
2243 arrayref of beginning and ending epoch date
2247 arrayref of beginning and ending epoch date
2251 arrayref of beginning and ending epoch date
2255 pkgnum or APKG_pkgnum
2259 a value suited to passing to FS::UI::Web::cust_header
2263 specifies the user for agent virtualization
2270 my ($class, $params) = @_;
2277 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2279 "cust_main.agentnum = $1";
2286 if ( $params->{'magic'} eq 'active'
2287 || $params->{'status'} eq 'active' ) {
2289 push @where, FS::cust_pkg->active_sql();
2291 } elsif ( $params->{'magic'} eq 'not yet billed'
2292 || $params->{'status'} eq 'not yet billed' ) {
2294 push @where, FS::cust_pkg->not_yet_billed_sql();
2296 } elsif ( $params->{'magic'} =~ /^(one-time charge|inactive)/
2297 || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2299 push @where, FS::cust_pkg->inactive_sql();
2301 } elsif ( $params->{'magic'} eq 'suspended'
2302 || $params->{'status'} eq 'suspended' ) {
2304 push @where, FS::cust_pkg->suspended_sql();
2306 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2307 || $params->{'status'} =~ /^cancell?ed$/ ) {
2309 push @where, FS::cust_pkg->cancelled_sql();
2314 # parse package class
2317 #false lazinessish w/graph/cust_bill_pkg.cgi
2320 if ( exists($params->{'classnum'})
2321 && $params->{'classnum'} =~ /^(\d*)$/
2325 if ( $classnum ) { #a specific class
2326 push @where, "classnum = $classnum";
2328 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2329 #die "classnum $classnum not found!" unless $pkg_class[0];
2330 #$title .= $pkg_class[0]->classname.' ';
2332 } elsif ( $classnum eq '' ) { #the empty class
2334 push @where, "classnum IS NULL";
2335 #$title .= 'Empty class ';
2336 #@pkg_class = ( '(empty class)' );
2337 } elsif ( $classnum eq '0' ) {
2338 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2339 #push @pkg_class, '(empty class)';
2341 die "illegal classnum";
2347 # parse package report options
2350 my @report_option = ();
2351 if ( exists($params->{'report_option'})
2352 && $params->{'report_option'} =~ /^([,\d]*)$/
2355 @report_option = split(',', $1);
2358 if (@report_option) {
2359 # this will result in the empty set for the dangling comma case as it should
2361 map{ "0 < ( SELECT count(*) FROM part_pkg_option
2362 WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
2363 AND optionname = 'report_option_$_'
2364 AND optionvalue = '1' )"
2374 push @where, "part_pkg.custom = 'Y'" if $params->{custom};
2380 if ( $params->{'censustract'} =~ /^([.\d]+)$/ and $1 ) {
2381 push @where, "cust_main.censustract = '". $params->{censustract}. "'";
2388 my $pkgpart = join (' OR pkgpart=',
2389 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
2390 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
2398 #false laziness w/report_cust_pkg.html
2401 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2402 'active' => { 'susp'=>1, 'cancel'=>1 },
2403 'suspended' => { 'cancel' => 1 },
2408 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2410 next unless exists($params->{$field});
2412 my($beginning, $ending) = @{$params->{$field}};
2414 next if $beginning == 0 && $ending == 4294967295;
2417 "cust_pkg.$field IS NOT NULL",
2418 "cust_pkg.$field >= $beginning",
2419 "cust_pkg.$field <= $ending";
2421 $orderby ||= "ORDER BY cust_pkg.$field";
2425 $orderby ||= 'ORDER BY bill';
2428 # parse magic, legacy, etc.
2431 if ( $params->{'magic'} &&
2432 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2435 $orderby = 'ORDER BY pkgnum';
2437 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2438 push @where, "pkgpart = $1";
2441 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2443 $orderby = 'ORDER BY pkgnum';
2445 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2447 $orderby = 'ORDER BY pkgnum';
2450 SELECT count(*) FROM pkg_svc
2451 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2452 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2453 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2454 AND cust_svc.svcpart = pkg_svc.svcpart
2461 # setup queries, links, subs, etc. for the search
2464 # here is the agent virtualization
2465 if ($params->{CurrentUser}) {
2467 qsearchs('access_user', { username => $params->{CurrentUser} });
2470 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2475 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2478 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2480 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2481 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2482 'LEFT JOIN pkg_class USING ( classnum ) ';
2484 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2487 'table' => 'cust_pkg',
2489 'select' => join(', ',
2491 ( map "part_pkg.$_", qw( pkg freq ) ),
2492 'pkg_class.classname',
2493 'cust_main.custnum as cust_main_custnum',
2494 FS::UI::Web::cust_sql_fields(
2495 $params->{'cust_fields'}
2498 'extra_sql' => "$extra_sql $orderby",
2499 'addl_from' => $addl_from,
2500 'count_query' => $count_query,
2507 Returns a list: the first item is an SQL fragment identifying matching
2508 packages/customers via location (taking into account shipping and package
2509 address taxation, if enabled), and subsequent items are the parameters to
2510 substitute for the placeholders in that fragment.
2515 my($class, %opt) = @_;
2516 my $ornull = $opt{'ornull'};
2518 my $conf = new FS::Conf;
2520 # '?' placeholders in _location_sql_where
2523 @bill_param = qw( county county state state state country );
2525 @bill_param = qw( county state state country );
2527 unshift @bill_param, 'county'; # unless $nec;
2531 if ( $conf->exists('tax-ship_address') ) {
2534 ( ( ship_last IS NULL OR ship_last = '' )
2535 AND ". _location_sql_where('cust_main', '', $ornull ). "
2537 OR ( ship_last IS NOT NULL AND ship_last != ''
2538 AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2541 # AND payby != 'COMP'
2543 @main_param = ( @bill_param, @bill_param );
2547 $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2548 @main_param = @bill_param;
2554 if ( $conf->exists('tax-pkg_address') ) {
2556 my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2559 ( cust_pkg.locationnum IS NULL AND $main_where )
2560 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where )
2563 @param = ( @main_param, @bill_param );
2567 $where = $main_where;
2568 @param = @main_param;
2576 #subroutine, helper for location_sql
2577 sub _location_sql_where {
2579 my $prefix = @_ ? shift : '';
2580 my $ornull = @_ ? shift : '';
2582 # $ornull = $ornull ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2584 $ornull = $ornull ? ' OR ? IS NULL ' : '';
2586 my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
2587 my $or_empty_state = " OR ( ? = '' AND $table.${prefix}state IS NULL ) ";
2590 ( $table.${prefix}county = ? $or_empty_county $ornull )
2591 AND ( $table.${prefix}state = ? $or_empty_state $ornull )
2592 AND $table.${prefix}country = ?
2600 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2602 CUSTNUM is a customer (see L<FS::cust_main>)
2604 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2605 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2608 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2609 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2610 new billing items. An error is returned if this is not possible (see
2611 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2614 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2615 newly-created cust_pkg objects.
2617 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2618 and inserted. Multiple FS::pkg_referral records can be created by
2619 setting I<refnum> to an array reference of refnums or a hash reference with
2620 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
2621 record will be created corresponding to cust_main.refnum.
2626 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2628 my $conf = new FS::Conf;
2630 # Transactionize this whole mess
2631 local $SIG{HUP} = 'IGNORE';
2632 local $SIG{INT} = 'IGNORE';
2633 local $SIG{QUIT} = 'IGNORE';
2634 local $SIG{TERM} = 'IGNORE';
2635 local $SIG{TSTP} = 'IGNORE';
2636 local $SIG{PIPE} = 'IGNORE';
2638 my $oldAutoCommit = $FS::UID::AutoCommit;
2639 local $FS::UID::AutoCommit = 0;
2643 # my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2644 # return "Customer not found: $custnum" unless $cust_main;
2646 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2649 my $change = scalar(@old_cust_pkg) != 0;
2652 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2654 my $err_or_cust_pkg =
2655 $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
2656 'refnum' => $refnum,
2659 unless (ref($err_or_cust_pkg)) {
2660 $dbh->rollback if $oldAutoCommit;
2661 return $err_or_cust_pkg;
2664 push @$return_cust_pkg, $err_or_cust_pkg;
2669 # Create the new packages.
2670 foreach my $pkgpart (@$pkgparts) {
2671 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2672 pkgpart => $pkgpart,
2676 $error = $cust_pkg->insert( 'change' => $change );
2678 $dbh->rollback if $oldAutoCommit;
2681 push @$return_cust_pkg, $cust_pkg;
2683 # $return_cust_pkg now contains refs to all of the newly
2686 # Transfer services and cancel old packages.
2687 foreach my $old_pkg (@old_cust_pkg) {
2689 foreach my $new_pkg (@$return_cust_pkg) {
2690 $error = $old_pkg->transfer($new_pkg);
2691 if ($error and $error == 0) {
2692 # $old_pkg->transfer failed.
2693 $dbh->rollback if $oldAutoCommit;
2698 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2699 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2700 foreach my $new_pkg (@$return_cust_pkg) {
2701 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2702 if ($error and $error == 0) {
2703 # $old_pkg->transfer failed.
2704 $dbh->rollback if $oldAutoCommit;
2711 # Transfers were successful, but we went through all of the
2712 # new packages and still had services left on the old package.
2713 # We can't cancel the package under the circumstances, so abort.
2714 $dbh->rollback if $oldAutoCommit;
2715 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2717 $error = $old_pkg->cancel( quiet=>1 );
2723 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2727 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2729 A bulk change method to change packages for multiple customers.
2731 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2732 L<FS::part_pkg>) to order for each customer. Duplicates are of course
2735 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2736 replace. The services (see L<FS::cust_svc>) are moved to the
2737 new billing items. An error is returned if this is not possible (see
2740 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2741 newly-created cust_pkg objects.
2746 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2748 # Transactionize this whole mess
2749 local $SIG{HUP} = 'IGNORE';
2750 local $SIG{INT} = 'IGNORE';
2751 local $SIG{QUIT} = 'IGNORE';
2752 local $SIG{TERM} = 'IGNORE';
2753 local $SIG{TSTP} = 'IGNORE';
2754 local $SIG{PIPE} = 'IGNORE';
2756 my $oldAutoCommit = $FS::UID::AutoCommit;
2757 local $FS::UID::AutoCommit = 0;
2761 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2764 while(scalar(@old_cust_pkg)) {
2766 my $custnum = $old_cust_pkg[0]->custnum;
2767 my (@remove) = map { $_->pkgnum }
2768 grep { $_->custnum == $custnum } @old_cust_pkg;
2769 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2771 my $error = order $custnum, $pkgparts, \@remove, \@return;
2773 push @errors, $error
2775 push @$return_cust_pkg, @return;
2778 if (scalar(@errors)) {
2779 $dbh->rollback if $oldAutoCommit;
2780 return join(' / ', @errors);
2783 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2789 Associates this package with a (suspension or cancellation) reason (see
2790 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2793 Available options are:
2799 can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
2803 the access_user (see L<FS::access_user>) providing the reason
2811 the action (cancel, susp, adjourn, expire) associated with the reason
2815 If there is an error, returns the error, otherwise returns false.
2820 my ($self, %options) = @_;
2822 my $otaker = $options{reason_otaker} ||
2823 $FS::CurrentUser::CurrentUser->username;
2826 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2830 } elsif ( ref($options{'reason'}) ) {
2832 return 'Enter a new reason (or select an existing one)'
2833 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2835 my $reason = new FS::reason({
2836 'reason_type' => $options{'reason'}->{'typenum'},
2837 'reason' => $options{'reason'}->{'reason'},
2839 my $error = $reason->insert;
2840 return $error if $error;
2842 $reasonnum = $reason->reasonnum;
2845 return "Unparsable reason: ". $options{'reason'};
2848 my $cust_pkg_reason =
2849 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2850 'reasonnum' => $reasonnum,
2851 'otaker' => $otaker,
2852 'action' => substr(uc($options{'action'}),0,1),
2853 'date' => $options{'date'}
2858 $cust_pkg_reason->insert;
2861 =item set_usage USAGE_VALUE_HASHREF
2863 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2864 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2865 upbytes, downbytes, and totalbytes are appropriate keys.
2867 All svc_accts which are part of this package have their values reset.
2872 my ($self, $valueref, %opt) = @_;
2874 foreach my $cust_svc ($self->cust_svc){
2875 my $svc_x = $cust_svc->svc_x;
2876 $svc_x->set_usage($valueref, %opt)
2877 if $svc_x->can("set_usage");
2881 =item recharge USAGE_VALUE_HASHREF
2883 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2884 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2885 upbytes, downbytes, and totalbytes are appropriate keys.
2887 All svc_accts which are part of this package have their values incremented.
2892 my ($self, $valueref) = @_;
2894 foreach my $cust_svc ($self->cust_svc){
2895 my $svc_x = $cust_svc->svc_x;
2896 $svc_x->recharge($valueref)
2897 if $svc_x->can("recharge");
2905 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2907 In sub order, the @pkgparts array (passed by reference) is clobbered.
2909 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2910 method to pass dates to the recur_prog expression, it should do so.
2912 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2913 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2914 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2915 configuration values. Probably need a subroutine which decides what to do
2916 based on whether or not we've fetched the user yet, rather than a hash. See
2917 FS::UID and the TODO.
2919 Now that things are transactional should the check in the insert method be
2924 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2925 L<FS::pkg_svc>, schema.html from the base documentation