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 $conf = new FS::Conf;
651 my $error = $self->cust_main->credit(
653 'Credit for unused time on '. $self->part_pkg->pkg,
654 'reason_type' => $conf->config('cancel_credit_type'),
657 $dbh->rollback if $oldAutoCommit;
658 return "Error crediting customer \$$remaining_value for unused time on".
659 $self->part_pkg->pkg. ": $error";
664 my %hash = $self->hash;
665 $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
666 my $new = new FS::cust_pkg ( \%hash );
667 $error = $new->replace( $self, options => { $self->options } );
669 $dbh->rollback if $oldAutoCommit;
673 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
674 return '' if $date; #no errors
676 my $conf = new FS::Conf;
677 my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
678 if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
679 my $conf = new FS::Conf;
680 my $error = send_email(
681 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
682 'to' => \@invoicing_list,
683 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
684 'body' => [ map "$_\n", $conf->config('cancelmessage') ],
686 #should this do something on errors?
693 =item cancel_if_expired [ NOW_TIMESTAMP ]
695 Cancels this package if its expire date has been reached.
699 sub cancel_if_expired {
701 my $time = shift || time;
702 return '' unless $self->expire && $self->expire <= $time;
703 my $error = $self->cancel;
705 return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
706 $self->custnum. ": $error";
713 Cancels any pending expiration (sets the expire field to null).
715 If there is an error, returns the error, otherwise returns false.
720 my( $self, %options ) = @_;
723 local $SIG{HUP} = 'IGNORE';
724 local $SIG{INT} = 'IGNORE';
725 local $SIG{QUIT} = 'IGNORE';
726 local $SIG{TERM} = 'IGNORE';
727 local $SIG{TSTP} = 'IGNORE';
728 local $SIG{PIPE} = 'IGNORE';
730 my $oldAutoCommit = $FS::UID::AutoCommit;
731 local $FS::UID::AutoCommit = 0;
734 my $old = $self->select_for_update;
736 my $pkgnum = $old->pkgnum;
737 if ( $old->get('cancel') || $self->get('cancel') ) {
738 dbh->rollback if $oldAutoCommit;
739 return "Can't unexpire cancelled package $pkgnum";
740 # or at least it's pointless
743 unless ( $old->get('expire') && $self->get('expire') ) {
744 dbh->rollback if $oldAutoCommit;
745 return ""; # no error
748 my %hash = $self->hash;
749 $hash{'expire'} = '';
750 my $new = new FS::cust_pkg ( \%hash );
751 $error = $new->replace( $self, options => { $self->options } );
753 $dbh->rollback if $oldAutoCommit;
757 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
763 =item suspend [ OPTION => VALUE ... ]
765 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
766 package, then suspends the package itself (sets the susp field to now).
768 Available options are:
772 =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.
774 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
778 If there is an error, returns the error, otherwise returns false.
783 my( $self, %options ) = @_;
786 local $SIG{HUP} = 'IGNORE';
787 local $SIG{INT} = 'IGNORE';
788 local $SIG{QUIT} = 'IGNORE';
789 local $SIG{TERM} = 'IGNORE';
790 local $SIG{TSTP} = 'IGNORE';
791 local $SIG{PIPE} = 'IGNORE';
793 my $oldAutoCommit = $FS::UID::AutoCommit;
794 local $FS::UID::AutoCommit = 0;
797 my $old = $self->select_for_update;
799 my $pkgnum = $old->pkgnum;
800 if ( $old->get('cancel') || $self->get('cancel') ) {
801 dbh->rollback if $oldAutoCommit;
802 return "Can't suspend cancelled package $pkgnum";
805 if ( $old->get('susp') || $self->get('susp') ) {
806 dbh->rollback if $oldAutoCommit;
807 return ""; # no error # complain on adjourn?
810 my $date = $options{date} if $options{date}; # adjourn/suspend later
811 $date = '' if ($date && $date <= time); # complain instead?
813 if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
814 dbh->rollback if $oldAutoCommit;
815 return "Package $pkgnum expires before it would be suspended.";
818 my $suspend_time = $options{'time'} || time;
820 if ( $options{'reason'} ) {
821 $error = $self->insert_reason( 'reason' => $options{'reason'},
822 'action' => $date ? 'adjourn' : 'suspend',
823 'date' => $date ? $date : $suspend_time,
824 'reason_otaker' => $options{'reason_otaker'},
827 dbh->rollback if $oldAutoCommit;
828 return "Error inserting cust_pkg_reason: $error";
836 foreach my $cust_svc (
837 qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
839 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
841 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
842 $dbh->rollback if $oldAutoCommit;
843 return "Illegal svcdb value in part_svc!";
846 require "FS/$svcdb.pm";
848 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
850 $error = $svc->suspend;
852 $dbh->rollback if $oldAutoCommit;
855 my( $label, $value ) = $cust_svc->label;
856 push @labels, "$label: $value";
860 my $conf = new FS::Conf;
861 if ( $conf->config('suspend_email_admin') ) {
863 my $error = send_email(
864 'from' => $conf->config('invoice_from', $self->cust_main->agentnum),
865 #invoice_from ??? well as good as any
866 'to' => $conf->config('suspend_email_admin'),
867 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
869 "This is an automatic message from your Freeside installation\n",
870 "informing you that the following customer package has been suspended:\n",
872 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
873 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
874 ( map { "Service : $_\n" } @labels ),
879 warn "WARNING: can't send suspension admin email (suspending anyway): ".
887 my %hash = $self->hash;
889 $hash{'adjourn'} = $date;
891 $hash{'susp'} = $suspend_time;
893 my $new = new FS::cust_pkg ( \%hash );
894 $error = $new->replace( $self, options => { $self->options } );
896 $dbh->rollback if $oldAutoCommit;
900 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
905 =item unsuspend [ OPTION => VALUE ... ]
907 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
908 package, then unsuspends the package itself (clears the susp field and the
909 adjourn field if it is in the past).
911 Available options are:
915 =item adjust_next_bill
917 Can be set true to adjust the next bill date forward by
918 the amount of time the account was inactive. This was set true by default
919 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
920 explicitly requested. Price plans for which this makes sense (anniversary-date
921 based than prorate or subscription) could have an option to enable this
926 If there is an error, returns the error, otherwise returns false.
931 my( $self, %opt ) = @_;
934 local $SIG{HUP} = 'IGNORE';
935 local $SIG{INT} = 'IGNORE';
936 local $SIG{QUIT} = 'IGNORE';
937 local $SIG{TERM} = 'IGNORE';
938 local $SIG{TSTP} = 'IGNORE';
939 local $SIG{PIPE} = 'IGNORE';
941 my $oldAutoCommit = $FS::UID::AutoCommit;
942 local $FS::UID::AutoCommit = 0;
945 my $old = $self->select_for_update;
947 my $pkgnum = $old->pkgnum;
948 if ( $old->get('cancel') || $self->get('cancel') ) {
949 dbh->rollback if $oldAutoCommit;
950 return "Can't unsuspend cancelled package $pkgnum";
953 unless ( $old->get('susp') && $self->get('susp') ) {
954 dbh->rollback if $oldAutoCommit;
955 return ""; # no error # complain instead?
958 foreach my $cust_svc (
959 qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
961 my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
963 $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
964 $dbh->rollback if $oldAutoCommit;
965 return "Illegal svcdb value in part_svc!";
968 require "FS/$svcdb.pm";
970 my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
972 $error = $svc->unsuspend;
974 $dbh->rollback if $oldAutoCommit;
981 my %hash = $self->hash;
982 my $inactive = time - $hash{'susp'};
984 my $conf = new FS::Conf;
986 $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
987 if ( $opt{'adjust_next_bill'}
988 || $conf->exists('unsuspend-always_adjust_next_bill_date') )
989 && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
992 $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
993 my $new = new FS::cust_pkg ( \%hash );
994 $error = $new->replace( $self, options => { $self->options } );
996 $dbh->rollback if $oldAutoCommit;
1000 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1007 Cancels any pending suspension (sets the adjourn field to null).
1009 If there is an error, returns the error, otherwise returns false.
1014 my( $self, %options ) = @_;
1017 local $SIG{HUP} = 'IGNORE';
1018 local $SIG{INT} = 'IGNORE';
1019 local $SIG{QUIT} = 'IGNORE';
1020 local $SIG{TERM} = 'IGNORE';
1021 local $SIG{TSTP} = 'IGNORE';
1022 local $SIG{PIPE} = 'IGNORE';
1024 my $oldAutoCommit = $FS::UID::AutoCommit;
1025 local $FS::UID::AutoCommit = 0;
1028 my $old = $self->select_for_update;
1030 my $pkgnum = $old->pkgnum;
1031 if ( $old->get('cancel') || $self->get('cancel') ) {
1032 dbh->rollback if $oldAutoCommit;
1033 return "Can't unadjourn cancelled package $pkgnum";
1034 # or at least it's pointless
1037 if ( $old->get('susp') || $self->get('susp') ) {
1038 dbh->rollback if $oldAutoCommit;
1039 return "Can't unadjourn suspended package $pkgnum";
1040 # perhaps this is arbitrary
1043 unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1044 dbh->rollback if $oldAutoCommit;
1045 return ""; # no error
1048 my %hash = $self->hash;
1049 $hash{'adjourn'} = '';
1050 my $new = new FS::cust_pkg ( \%hash );
1051 $error = $new->replace( $self, options => { $self->options } );
1053 $dbh->rollback if $oldAutoCommit;
1057 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1064 =item change HASHREF | OPTION => VALUE ...
1066 Changes this package: cancels it and creates a new one, with a different
1067 pkgpart or locationnum or both. All services are transferred to the new
1068 package (no change will be made if this is not possible).
1070 Options may be passed as a list of key/value pairs or as a hash reference.
1077 New locationnum, to change the location for this package.
1081 New FS::cust_location object, to create a new location and assign it
1086 New pkgpart (see L<FS::part_pkg>).
1090 New refnum (see L<FS::part_referral>).
1094 At least one option must be specified (otherwise, what's the point?)
1096 Returns either the new FS::cust_pkg object or a scalar error.
1100 my $err_or_new_cust_pkg = $old_cust_pkg->change
1104 #some false laziness w/order
1107 my $opt = ref($_[0]) ? shift : { @_ };
1109 # my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1112 my $conf = new FS::Conf;
1114 # Transactionize this whole mess
1115 local $SIG{HUP} = 'IGNORE';
1116 local $SIG{INT} = 'IGNORE';
1117 local $SIG{QUIT} = 'IGNORE';
1118 local $SIG{TERM} = 'IGNORE';
1119 local $SIG{TSTP} = 'IGNORE';
1120 local $SIG{PIPE} = 'IGNORE';
1122 my $oldAutoCommit = $FS::UID::AutoCommit;
1123 local $FS::UID::AutoCommit = 0;
1132 #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1134 #$hash{$_} = $self->$_() foreach qw( setup );
1136 $hash{'setup'} = $time if $self->setup;
1138 $hash{'change_date'} = $time;
1139 $hash{"change_$_"} = $self->$_()
1140 foreach qw( pkgnum pkgpart locationnum );
1142 if ( $opt->{'cust_location'} &&
1143 ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1144 $error = $opt->{'cust_location'}->insert;
1146 $dbh->rollback if $oldAutoCommit;
1147 return "inserting cust_location (transaction rolled back): $error";
1149 $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1152 # Create the new package.
1153 my $cust_pkg = new FS::cust_pkg {
1154 custnum => $self->custnum,
1155 pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ),
1156 refnum => ( $opt->{'refnum'} || $self->refnum ),
1157 locationnum => ( $opt->{'locationnum'} || $self->locationnum ),
1161 $error = $cust_pkg->insert( 'change' => 1 );
1163 $dbh->rollback if $oldAutoCommit;
1167 # Transfer services and cancel old package.
1169 $error = $self->transfer($cust_pkg);
1170 if ($error and $error == 0) {
1171 # $old_pkg->transfer failed.
1172 $dbh->rollback if $oldAutoCommit;
1176 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1177 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1178 $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1179 if ($error and $error == 0) {
1180 # $old_pkg->transfer failed.
1181 $dbh->rollback if $oldAutoCommit;
1187 # Transfers were successful, but we still had services left on the old
1188 # package. We can't change the package under this circumstances, so abort.
1189 $dbh->rollback if $oldAutoCommit;
1190 return "Unable to transfer all services from package ". $self->pkgnum;
1193 #reset usage if changing pkgpart
1194 if ($self->pkgpart != $cust_pkg->pkgpart) {
1195 my $part_pkg = $cust_pkg->part_pkg;
1196 $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1200 if $part_pkg->can('reset_usage');
1203 $dbh->rollback if $oldAutoCommit;
1204 return "Error setting usage values: $error";
1208 #Good to go, cancel old package.
1209 $error = $self->cancel( quiet=>1 );
1215 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1222 Returns the last bill date, or if there is no last bill date, the setup date.
1223 Useful for billing metered services.
1229 return $self->setfield('last_bill', $_[0]) if @_;
1230 return $self->getfield('last_bill') if $self->getfield('last_bill');
1231 my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1232 'edate' => $self->bill, } );
1233 $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1236 =item last_cust_pkg_reason ACTION
1238 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1239 Returns false if there is no reason or the package is not currenly ACTION'd
1240 ACTION is one of adjourn, susp, cancel, or expire.
1244 sub last_cust_pkg_reason {
1245 my ( $self, $action ) = ( shift, shift );
1246 my $date = $self->get($action);
1248 'table' => 'cust_pkg_reason',
1249 'hashref' => { 'pkgnum' => $self->pkgnum,
1250 'action' => substr(uc($action), 0, 1),
1253 'order_by' => 'ORDER BY num DESC LIMIT 1',
1257 =item last_reason ACTION
1259 Returns the most recent ACTION FS::reason associated with the package.
1260 Returns false if there is no reason or the package is not currenly ACTION'd
1261 ACTION is one of adjourn, susp, cancel, or expire.
1266 my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1267 $cust_pkg_reason->reason
1268 if $cust_pkg_reason;
1273 Returns the definition for this billing item, as an FS::part_pkg object (see
1280 return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1281 cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1282 qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1287 Returns the cancelled package this package was changed from, if any.
1293 return '' unless $self->change_pkgnum;
1294 qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1299 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1306 $self->part_pkg->calc_setup($self, @_);
1311 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1318 $self->part_pkg->calc_recur($self, @_);
1323 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1330 $self->part_pkg->calc_remain($self, @_);
1335 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1342 $self->part_pkg->calc_cancel($self, @_);
1347 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1353 qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1356 =item cust_pkg_detail [ DETAILTYPE ]
1358 Returns any customer package details for this package (see
1359 L<FS::cust_pkg_detail>).
1361 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1365 sub cust_pkg_detail {
1367 my %hash = ( 'pkgnum' => $self->pkgnum );
1368 $hash{detailtype} = shift if @_;
1370 'table' => 'cust_pkg_detail',
1371 'hashref' => \%hash,
1372 'order_by' => 'ORDER BY weight, pkgdetailnum',
1376 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1378 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1380 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1382 If there is an error, returns the error, otherwise returns false.
1386 sub set_cust_pkg_detail {
1387 my( $self, $detailtype, @details ) = @_;
1389 local $SIG{HUP} = 'IGNORE';
1390 local $SIG{INT} = 'IGNORE';
1391 local $SIG{QUIT} = 'IGNORE';
1392 local $SIG{TERM} = 'IGNORE';
1393 local $SIG{TSTP} = 'IGNORE';
1394 local $SIG{PIPE} = 'IGNORE';
1396 my $oldAutoCommit = $FS::UID::AutoCommit;
1397 local $FS::UID::AutoCommit = 0;
1400 foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1401 my $error = $current->delete;
1403 $dbh->rollback if $oldAutoCommit;
1404 return "error removing old detail: $error";
1408 foreach my $detail ( @details ) {
1409 my $cust_pkg_detail = new FS::cust_pkg_detail {
1410 'pkgnum' => $self->pkgnum,
1411 'detailtype' => $detailtype,
1412 'detail' => $detail,
1414 my $error = $cust_pkg_detail->insert;
1416 $dbh->rollback if $oldAutoCommit;
1417 return "error adding new detail: $error";
1422 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1429 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1433 #false laziness w/cust_bill.pm
1437 'table' => 'cust_event',
1438 'addl_from' => 'JOIN part_event USING ( eventpart )',
1439 'hashref' => { 'tablenum' => $self->pkgnum },
1440 'extra_sql' => " AND eventtable = 'cust_pkg' ",
1444 =item num_cust_event
1446 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1450 #false laziness w/cust_bill.pm
1451 sub num_cust_event {
1454 "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1455 " WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1456 my $sth = dbh->prepare($sql) or die dbh->errstr. " preparing $sql";
1457 $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1458 $sth->fetchrow_arrayref->[0];
1461 =item cust_svc [ SVCPART ]
1463 Returns the services for this package, as FS::cust_svc objects (see
1464 L<FS::cust_svc>). If a svcpart is specified, return only the matching
1472 return () unless $self->num_cust_svc(@_);
1475 return qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum,
1476 'svcpart' => shift, } );
1479 cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
1481 #if ( $self->{'_svcnum'} ) {
1482 # values %{ $self->{'_svcnum'}->cache };
1484 $self->_sort_cust_svc(
1485 [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1491 =item overlimit [ SVCPART ]
1493 Returns the services for this package which have exceeded their
1494 usage limit as FS::cust_svc objects (see L<FS::cust_svc>). If a svcpart
1495 is specified, return only the matching services.
1501 return () unless $self->num_cust_svc(@_);
1502 grep { $_->overlimit } $self->cust_svc(@_);
1505 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ]
1507 Returns historical services for this package created before END TIMESTAMP and
1508 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1509 (see L<FS::h_cust_svc>).
1516 $self->_sort_cust_svc(
1517 [ qsearch( 'h_cust_svc',
1518 { 'pkgnum' => $self->pkgnum, },
1519 FS::h_cust_svc->sql_h_search(@_),
1525 sub _sort_cust_svc {
1526 my( $self, $arrayref ) = @_;
1529 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
1531 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1532 'svcpart' => $_->svcpart } );
1534 $pkg_svc ? $pkg_svc->primary_svc : '',
1535 $pkg_svc ? $pkg_svc->quantity : 0,
1542 =item num_cust_svc [ SVCPART ]
1544 Returns the number of provisioned services for this package. If a svcpart is
1545 specified, counts only the matching services.
1552 return $self->{'_num_cust_svc'}
1554 && exists($self->{'_num_cust_svc'})
1555 && $self->{'_num_cust_svc'} =~ /\d/;
1557 cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
1560 my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1561 $sql .= ' AND svcpart = ?' if @_;
1563 my $sth = dbh->prepare($sql) or die dbh->errstr;
1564 $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1565 $sth->fetchrow_arrayref->[0];
1568 =item available_part_svc
1570 Returns a list of FS::part_svc objects representing services included in this
1571 package but not yet provisioned. Each FS::part_svc object also has an extra
1572 field, I<num_avail>, which specifies the number of available services.
1576 sub available_part_svc {
1578 grep { $_->num_avail > 0 }
1580 my $part_svc = $_->part_svc;
1581 $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1582 $_->quantity - $self->num_cust_svc($_->svcpart);
1585 $self->part_pkg->pkg_svc;
1590 Returns a list of FS::part_svc objects representing provisioned and available
1591 services included in this package. Each FS::part_svc object also has the
1592 following extra fields:
1596 =item num_cust_svc (count)
1598 =item num_avail (quantity - count)
1600 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1603 label -> ($cust_svc->label)[1]
1612 #XXX some sort of sort order besides numeric by svcpart...
1613 my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1615 my $part_svc = $pkg_svc->part_svc;
1616 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1617 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1618 $part_svc->{'Hash'}{'num_avail'} =
1619 max( 0, $pkg_svc->quantity - $num_cust_svc );
1620 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1621 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1623 } $self->part_pkg->pkg_svc;
1626 push @part_svc, map {
1628 my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1629 $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1630 $part_svc->{'Hash'}{'num_avail'} = 0; #0-$num_cust_svc ?
1631 $part_svc->{'Hash'}{'cust_pkg_svc'} =
1632 $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1634 } $self->extra_part_svc;
1640 =item extra_part_svc
1642 Returns a list of FS::part_svc objects corresponding to services in this
1643 package which are still provisioned but not (any longer) available in the
1648 sub extra_part_svc {
1651 my $pkgnum = $self->pkgnum;
1652 my $pkgpart = $self->pkgpart;
1655 # 'table' => 'part_svc',
1658 # "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc
1659 # WHERE pkg_svc.svcpart = part_svc.svcpart
1660 # AND pkg_svc.pkgpart = ?
1663 # AND 0 < ( SELECT COUNT(*) FROM cust_svc
1664 # LEFT JOIN cust_pkg USING ( pkgnum )
1665 # WHERE cust_svc.svcpart = part_svc.svcpart
1668 # 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1671 #seems to benchmark slightly faster...
1673 'select' => 'DISTINCT ON (svcpart) part_svc.*',
1674 'table' => 'part_svc',
1676 'LEFT JOIN pkg_svc ON ( pkg_svc.svcpart = part_svc.svcpart
1677 AND pkg_svc.pkgpart = ?
1680 LEFT JOIN cust_svc ON ( cust_svc.svcpart = part_svc.svcpart )
1681 LEFT JOIN cust_pkg USING ( pkgnum )
1684 'extra_sql' => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1685 'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1691 Returns a short status string for this package, currently:
1695 =item not yet billed
1697 =item one-time charge
1712 my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1714 return 'cancelled' if $self->get('cancel');
1715 return 'suspended' if $self->susp;
1716 return 'not yet billed' unless $self->setup;
1717 return 'one-time charge' if $freq =~ /^(0|$)/;
1723 Class method that returns the list of possible status strings for packages
1724 (see L<the status method|/status>). For example:
1726 @statuses = FS::cust_pkg->statuses();
1730 tie my %statuscolor, 'Tie::IxHash',
1731 'not yet billed' => '000000',
1732 'one-time charge' => '000000',
1733 'active' => '00CC00',
1734 'suspended' => 'FF9900',
1735 'cancelled' => 'FF0000',
1739 my $self = shift; #could be class...
1740 #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1741 # # mayble split btw one-time vs. recur
1747 Returns a hex triplet color string for this package's status.
1753 $statuscolor{$self->status};
1758 Returns a list of lists, calling the label method for all services
1759 (see L<FS::cust_svc>) of this billing item.
1765 map { [ $_->label ] } $self->cust_svc;
1768 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ]
1770 Like the labels method, but returns historical information on services that
1771 were active as of END_TIMESTAMP and (optionally) not cancelled before
1774 Returns a list of lists, calling the label method for all (historical) services
1775 (see L<FS::h_cust_svc>) of this billing item.
1781 map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1784 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1786 Like h_labels, except returns a simple flat list, and shortens long
1787 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1788 identical services to one line that lists the service label and the number of
1789 individual services rather than individual items.
1793 sub h_labels_short {
1796 my $conf = new FS::Conf;
1797 my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1800 #tie %labels, 'Tie::IxHash';
1801 push @{ $labels{$_->[0]} }, $_->[1]
1802 foreach $self->h_labels(@_);
1804 foreach my $label ( keys %labels ) {
1806 my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1807 my $num = scalar(@values);
1808 if ( $num > $max_same_services ) {
1809 push @labels, "$label ($num)";
1811 push @labels, map { "$label: $_" } @values;
1821 Returns the parent customer object (see L<FS::cust_main>).
1827 qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1832 Returns the location object, if any (see L<FS::cust_location>).
1838 return '' unless $self->locationnum;
1839 qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
1842 =item cust_location_or_main
1844 If this package is associated with a location, returns the locaiton (see
1845 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1849 sub cust_location_or_main {
1851 $self->cust_location || $self->cust_main;
1854 =item seconds_since TIMESTAMP
1856 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1857 package have been online since TIMESTAMP, according to the session monitor.
1859 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
1860 L<Time::Local> and L<Date::Parse> for conversion functions.
1865 my($self, $since) = @_;
1868 foreach my $cust_svc (
1869 grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1871 $seconds += $cust_svc->seconds_since($since);
1878 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1880 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1881 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1884 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1885 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1891 sub seconds_since_sqlradacct {
1892 my($self, $start, $end) = @_;
1896 foreach my $cust_svc (
1898 my $part_svc = $_->part_svc;
1899 $part_svc->svcdb eq 'svc_acct'
1900 && scalar($part_svc->part_export('sqlradius'));
1903 $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1910 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
1912 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
1913 in this package for sessions ending between TIMESTAMP_START (inclusive) and
1917 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1918 L<perlfunc/"time">. Also see L<Time::Local> and L<Date::Parse> for conversion
1923 sub attribute_since_sqlradacct {
1924 my($self, $start, $end, $attrib) = @_;
1928 foreach my $cust_svc (
1930 my $part_svc = $_->part_svc;
1931 $part_svc->svcdb eq 'svc_acct'
1932 && scalar($part_svc->part_export('sqlradius'));
1935 $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
1947 my( $self, $value ) = @_;
1948 if ( defined($value) ) {
1949 $self->setfield('quantity', $value);
1951 $self->getfield('quantity') || 1;
1954 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
1956 Transfers as many services as possible from this package to another package.
1958 The destination package can be specified by pkgnum by passing an FS::cust_pkg
1959 object. The destination package must already exist.
1961 Services are moved only if the destination allows services with the correct
1962 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true. Use
1963 this option with caution! No provision is made for export differences
1964 between the old and new service definitions. Probably only should be used
1965 when your exports for all service definitions of a given svcdb are identical.
1966 (attempt a transfer without it first, to move all possible svcpart-matching
1969 Any services that can't be moved remain in the original package.
1971 Returns an error, if there is one; otherwise, returns the number of services
1972 that couldn't be moved.
1977 my ($self, $dest_pkgnum, %opt) = @_;
1983 if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
1984 $dest = $dest_pkgnum;
1985 $dest_pkgnum = $dest->pkgnum;
1987 $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
1990 return ('Package does not exist: '.$dest_pkgnum) unless $dest;
1992 foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
1993 $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
1996 foreach my $cust_svc ($dest->cust_svc) {
1997 $target{$cust_svc->svcpart}--;
2000 my %svcpart2svcparts = ();
2001 if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2002 warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2003 foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2004 next if exists $svcpart2svcparts{$svcpart};
2005 my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2006 $svcpart2svcparts{$svcpart} = [
2008 sort { $b->[1] cmp $a->[1] or $a->[2] <=> $b->[2] }
2010 my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2011 'svcpart' => $_ } );
2013 $pkg_svc ? $pkg_svc->primary_svc : '',
2014 $pkg_svc ? $pkg_svc->quantity : 0,
2018 grep { $_ != $svcpart }
2020 qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2022 warn "alternates for svcpart $svcpart: ".
2023 join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2028 foreach my $cust_svc ($self->cust_svc) {
2029 if($target{$cust_svc->svcpart} > 0) {
2030 $target{$cust_svc->svcpart}--;
2031 my $new = new FS::cust_svc { $cust_svc->hash };
2032 $new->pkgnum($dest_pkgnum);
2033 my $error = $new->replace($cust_svc);
2034 return $error if $error;
2035 } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2037 warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2038 warn "alternates to consider: ".
2039 join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2041 my @alternate = grep {
2042 warn "considering alternate svcpart $_: ".
2043 "$target{$_} available in new package\n"
2046 } @{$svcpart2svcparts{$cust_svc->svcpart}};
2048 warn "alternate(s) found\n" if $DEBUG;
2049 my $change_svcpart = $alternate[0];
2050 $target{$change_svcpart}--;
2051 my $new = new FS::cust_svc { $cust_svc->hash };
2052 $new->svcpart($change_svcpart);
2053 $new->pkgnum($dest_pkgnum);
2054 my $error = $new->replace($cust_svc);
2055 return $error if $error;
2068 This method is deprecated. See the I<depend_jobnum> option to the insert and
2069 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2076 local $SIG{HUP} = 'IGNORE';
2077 local $SIG{INT} = 'IGNORE';
2078 local $SIG{QUIT} = 'IGNORE';
2079 local $SIG{TERM} = 'IGNORE';
2080 local $SIG{TSTP} = 'IGNORE';
2081 local $SIG{PIPE} = 'IGNORE';
2083 my $oldAutoCommit = $FS::UID::AutoCommit;
2084 local $FS::UID::AutoCommit = 0;
2087 foreach my $cust_svc ( $self->cust_svc ) {
2088 #false laziness w/svc_Common::insert
2089 my $svc_x = $cust_svc->svc_x;
2090 foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2091 my $error = $part_export->export_insert($svc_x);
2093 $dbh->rollback if $oldAutoCommit;
2099 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2106 =head1 CLASS METHODS
2112 Returns an SQL expression identifying recurring packages.
2116 sub recurring_sql { "
2117 '0' != ( select freq from part_pkg
2118 where cust_pkg.pkgpart = part_pkg.pkgpart )
2123 Returns an SQL expression identifying one-time packages.
2128 '0' = ( select freq from part_pkg
2129 where cust_pkg.pkgpart = part_pkg.pkgpart )
2134 Returns an SQL expression identifying active packages.
2139 ". $_[0]->recurring_sql(). "
2140 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2141 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2144 =item not_yet_billed_sql
2146 Returns an SQL expression identifying packages which have not yet been billed.
2150 sub not_yet_billed_sql { "
2151 ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 )
2152 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2153 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2158 Returns an SQL expression identifying inactive packages (one-time packages
2159 that are otherwise unsuspended/uncancelled).
2163 sub inactive_sql { "
2164 ". $_[0]->onetime_sql(). "
2165 AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2166 AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2167 AND ( cust_pkg.susp IS NULL OR cust_pkg.susp = 0 )
2173 Returns an SQL expression identifying suspended packages.
2177 sub suspended_sql { susp_sql(@_); }
2179 #$_[0]->recurring_sql(). ' AND '.
2181 ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2182 AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0
2189 Returns an SQL exprression identifying cancelled packages.
2193 sub cancelled_sql { cancel_sql(@_); }
2195 #$_[0]->recurring_sql(). ' AND '.
2196 "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2199 =item search_sql HASHREF
2203 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2204 Valid parameters are
2212 active, inactive, suspended, cancel (or cancelled)
2216 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2220 boolean selects custom packages
2230 arrayref of beginning and ending epoch date
2234 arrayref of beginning and ending epoch date
2238 arrayref of beginning and ending epoch date
2242 arrayref of beginning and ending epoch date
2246 arrayref of beginning and ending epoch date
2250 arrayref of beginning and ending epoch date
2254 arrayref of beginning and ending epoch date
2258 pkgnum or APKG_pkgnum
2262 a value suited to passing to FS::UI::Web::cust_header
2266 specifies the user for agent virtualization
2273 my ($class, $params) = @_;
2280 if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2282 "cust_main.agentnum = $1";
2289 if ( $params->{'magic'} eq 'active'
2290 || $params->{'status'} eq 'active' ) {
2292 push @where, FS::cust_pkg->active_sql();
2294 } elsif ( $params->{'magic'} eq 'not yet billed'
2295 || $params->{'status'} eq 'not yet billed' ) {
2297 push @where, FS::cust_pkg->not_yet_billed_sql();
2299 } elsif ( $params->{'magic'} =~ /^(one-time charge|inactive)/
2300 || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2302 push @where, FS::cust_pkg->inactive_sql();
2304 } elsif ( $params->{'magic'} eq 'suspended'
2305 || $params->{'status'} eq 'suspended' ) {
2307 push @where, FS::cust_pkg->suspended_sql();
2309 } elsif ( $params->{'magic'} =~ /^cancell?ed$/
2310 || $params->{'status'} =~ /^cancell?ed$/ ) {
2312 push @where, FS::cust_pkg->cancelled_sql();
2317 # parse package class
2320 #false lazinessish w/graph/cust_bill_pkg.cgi
2323 if ( exists($params->{'classnum'})
2324 && $params->{'classnum'} =~ /^(\d*)$/
2328 if ( $classnum ) { #a specific class
2329 push @where, "classnum = $classnum";
2331 #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2332 #die "classnum $classnum not found!" unless $pkg_class[0];
2333 #$title .= $pkg_class[0]->classname.' ';
2335 } elsif ( $classnum eq '' ) { #the empty class
2337 push @where, "classnum IS NULL";
2338 #$title .= 'Empty class ';
2339 #@pkg_class = ( '(empty class)' );
2340 } elsif ( $classnum eq '0' ) {
2341 #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2342 #push @pkg_class, '(empty class)';
2344 die "illegal classnum";
2350 # parse package report options
2353 my @report_option = ();
2354 if ( exists($params->{'report_option'})
2355 && $params->{'report_option'} =~ /^([,\d]*)$/
2358 @report_option = split(',', $1);
2361 if (@report_option) {
2362 # this will result in the empty set for the dangling comma case as it should
2364 map{ "0 < ( SELECT count(*) FROM part_pkg_option
2365 WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
2366 AND optionname = 'report_option_$_'
2367 AND optionvalue = '1' )"
2377 push @where, "part_pkg.custom = 'Y'" if $params->{custom};
2383 if ( $params->{'censustract'} =~ /^([.\d]+)$/ and $1 ) {
2384 push @where, "cust_main.censustract = '". $params->{censustract}. "'";
2391 my $pkgpart = join (' OR pkgpart=',
2392 grep {$_} map { /^(\d+)$/; } ($params->{'pkgpart'}));
2393 push @where, '(pkgpart=' . $pkgpart . ')' if $pkgpart;
2401 #false laziness w/report_cust_pkg.html
2404 'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2405 'active' => { 'susp'=>1, 'cancel'=>1 },
2406 'suspended' => { 'cancel' => 1 },
2411 foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2413 next unless exists($params->{$field});
2415 my($beginning, $ending) = @{$params->{$field}};
2417 next if $beginning == 0 && $ending == 4294967295;
2420 "cust_pkg.$field IS NOT NULL",
2421 "cust_pkg.$field >= $beginning",
2422 "cust_pkg.$field <= $ending";
2424 $orderby ||= "ORDER BY cust_pkg.$field";
2428 $orderby ||= 'ORDER BY bill';
2431 # parse magic, legacy, etc.
2434 if ( $params->{'magic'} &&
2435 $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2438 $orderby = 'ORDER BY pkgnum';
2440 if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2441 push @where, "pkgpart = $1";
2444 } elsif ( $params->{'query'} eq 'pkgnum' ) {
2446 $orderby = 'ORDER BY pkgnum';
2448 } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2450 $orderby = 'ORDER BY pkgnum';
2453 SELECT count(*) FROM pkg_svc
2454 WHERE pkg_svc.pkgpart = cust_pkg.pkgpart
2455 AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2456 WHERE cust_svc.pkgnum = cust_pkg.pkgnum
2457 AND cust_svc.svcpart = pkg_svc.svcpart
2464 # setup queries, links, subs, etc. for the search
2467 # here is the agent virtualization
2468 if ($params->{CurrentUser}) {
2470 qsearchs('access_user', { username => $params->{CurrentUser} });
2473 push @where, $access_user->agentnums_sql('table'=>'cust_main');
2478 push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2481 my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2483 my $addl_from = 'LEFT JOIN cust_main USING ( custnum ) '.
2484 'LEFT JOIN part_pkg USING ( pkgpart ) '.
2485 'LEFT JOIN pkg_class USING ( classnum ) ';
2487 my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2490 'table' => 'cust_pkg',
2492 'select' => join(', ',
2494 ( map "part_pkg.$_", qw( pkg freq ) ),
2495 'pkg_class.classname',
2496 'cust_main.custnum as cust_main_custnum',
2497 FS::UI::Web::cust_sql_fields(
2498 $params->{'cust_fields'}
2501 'extra_sql' => "$extra_sql $orderby",
2502 'addl_from' => $addl_from,
2503 'count_query' => $count_query,
2510 Returns a list: the first item is an SQL fragment identifying matching
2511 packages/customers via location (taking into account shipping and package
2512 address taxation, if enabled), and subsequent items are the parameters to
2513 substitute for the placeholders in that fragment.
2518 my($class, %opt) = @_;
2519 my $ornull = $opt{'ornull'};
2521 my $conf = new FS::Conf;
2523 # '?' placeholders in _location_sql_where
2526 @bill_param = qw( county county state state state country );
2528 @bill_param = qw( county state state country );
2530 unshift @bill_param, 'county'; # unless $nec;
2534 if ( $conf->exists('tax-ship_address') ) {
2537 ( ( ship_last IS NULL OR ship_last = '' )
2538 AND ". _location_sql_where('cust_main', '', $ornull ). "
2540 OR ( ship_last IS NOT NULL AND ship_last != ''
2541 AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2544 # AND payby != 'COMP'
2546 @main_param = ( @bill_param, @bill_param );
2550 $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2551 @main_param = @bill_param;
2557 if ( $conf->exists('tax-pkg_address') ) {
2559 my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2562 ( cust_pkg.locationnum IS NULL AND $main_where )
2563 OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where )
2566 @param = ( @main_param, @bill_param );
2570 $where = $main_where;
2571 @param = @main_param;
2579 #subroutine, helper for location_sql
2580 sub _location_sql_where {
2582 my $prefix = @_ ? shift : '';
2583 my $ornull = @_ ? shift : '';
2585 # $ornull = $ornull ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2587 $ornull = $ornull ? ' OR ? IS NULL ' : '';
2589 my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
2590 my $or_empty_state = " OR ( ? = '' AND $table.${prefix}state IS NULL ) ";
2593 ( $table.${prefix}county = ? $or_empty_county $ornull )
2594 AND ( $table.${prefix}state = ? $or_empty_state $ornull )
2595 AND $table.${prefix}country = ?
2603 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2605 CUSTNUM is a customer (see L<FS::cust_main>)
2607 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2608 L<FS::part_pkg>) to order for this customer. Duplicates are of course
2611 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2612 remove for this customer. The services (see L<FS::cust_svc>) are moved to the
2613 new billing items. An error is returned if this is not possible (see
2614 L<FS::pkg_svc>). An empty arrayref is equivalent to not specifying this
2617 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2618 newly-created cust_pkg objects.
2620 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2621 and inserted. Multiple FS::pkg_referral records can be created by
2622 setting I<refnum> to an array reference of refnums or a hash reference with
2623 refnums as keys. If no I<refnum> is defined, a default FS::pkg_referral
2624 record will be created corresponding to cust_main.refnum.
2629 my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2631 my $conf = new FS::Conf;
2633 # Transactionize this whole mess
2634 local $SIG{HUP} = 'IGNORE';
2635 local $SIG{INT} = 'IGNORE';
2636 local $SIG{QUIT} = 'IGNORE';
2637 local $SIG{TERM} = 'IGNORE';
2638 local $SIG{TSTP} = 'IGNORE';
2639 local $SIG{PIPE} = 'IGNORE';
2641 my $oldAutoCommit = $FS::UID::AutoCommit;
2642 local $FS::UID::AutoCommit = 0;
2646 # my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2647 # return "Customer not found: $custnum" unless $cust_main;
2649 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2652 my $change = scalar(@old_cust_pkg) != 0;
2655 if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2657 my $err_or_cust_pkg =
2658 $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
2659 'refnum' => $refnum,
2662 unless (ref($err_or_cust_pkg)) {
2663 $dbh->rollback if $oldAutoCommit;
2664 return $err_or_cust_pkg;
2667 push @$return_cust_pkg, $err_or_cust_pkg;
2672 # Create the new packages.
2673 foreach my $pkgpart (@$pkgparts) {
2674 my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2675 pkgpart => $pkgpart,
2679 $error = $cust_pkg->insert( 'change' => $change );
2681 $dbh->rollback if $oldAutoCommit;
2684 push @$return_cust_pkg, $cust_pkg;
2686 # $return_cust_pkg now contains refs to all of the newly
2689 # Transfer services and cancel old packages.
2690 foreach my $old_pkg (@old_cust_pkg) {
2692 foreach my $new_pkg (@$return_cust_pkg) {
2693 $error = $old_pkg->transfer($new_pkg);
2694 if ($error and $error == 0) {
2695 # $old_pkg->transfer failed.
2696 $dbh->rollback if $oldAutoCommit;
2701 if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2702 warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2703 foreach my $new_pkg (@$return_cust_pkg) {
2704 $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2705 if ($error and $error == 0) {
2706 # $old_pkg->transfer failed.
2707 $dbh->rollback if $oldAutoCommit;
2714 # Transfers were successful, but we went through all of the
2715 # new packages and still had services left on the old package.
2716 # We can't cancel the package under the circumstances, so abort.
2717 $dbh->rollback if $oldAutoCommit;
2718 return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2720 $error = $old_pkg->cancel( quiet=>1 );
2726 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2730 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2732 A bulk change method to change packages for multiple customers.
2734 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2735 L<FS::part_pkg>) to order for each customer. Duplicates are of course
2738 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2739 replace. The services (see L<FS::cust_svc>) are moved to the
2740 new billing items. An error is returned if this is not possible (see
2743 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2744 newly-created cust_pkg objects.
2749 my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2751 # Transactionize this whole mess
2752 local $SIG{HUP} = 'IGNORE';
2753 local $SIG{INT} = 'IGNORE';
2754 local $SIG{QUIT} = 'IGNORE';
2755 local $SIG{TERM} = 'IGNORE';
2756 local $SIG{TSTP} = 'IGNORE';
2757 local $SIG{PIPE} = 'IGNORE';
2759 my $oldAutoCommit = $FS::UID::AutoCommit;
2760 local $FS::UID::AutoCommit = 0;
2764 my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2767 while(scalar(@old_cust_pkg)) {
2769 my $custnum = $old_cust_pkg[0]->custnum;
2770 my (@remove) = map { $_->pkgnum }
2771 grep { $_->custnum == $custnum } @old_cust_pkg;
2772 @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2774 my $error = order $custnum, $pkgparts, \@remove, \@return;
2776 push @errors, $error
2778 push @$return_cust_pkg, @return;
2781 if (scalar(@errors)) {
2782 $dbh->rollback if $oldAutoCommit;
2783 return join(' / ', @errors);
2786 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2792 Associates this package with a (suspension or cancellation) reason (see
2793 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2796 Available options are:
2802 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.
2806 the access_user (see L<FS::access_user>) providing the reason
2814 the action (cancel, susp, adjourn, expire) associated with the reason
2818 If there is an error, returns the error, otherwise returns false.
2823 my ($self, %options) = @_;
2825 my $otaker = $options{reason_otaker} ||
2826 $FS::CurrentUser::CurrentUser->username;
2829 if ( $options{'reason'} =~ /^(\d+)$/ ) {
2833 } elsif ( ref($options{'reason'}) ) {
2835 return 'Enter a new reason (or select an existing one)'
2836 unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2838 my $reason = new FS::reason({
2839 'reason_type' => $options{'reason'}->{'typenum'},
2840 'reason' => $options{'reason'}->{'reason'},
2842 my $error = $reason->insert;
2843 return $error if $error;
2845 $reasonnum = $reason->reasonnum;
2848 return "Unparsable reason: ". $options{'reason'};
2851 my $cust_pkg_reason =
2852 new FS::cust_pkg_reason({ 'pkgnum' => $self->pkgnum,
2853 'reasonnum' => $reasonnum,
2854 'otaker' => $otaker,
2855 'action' => substr(uc($options{'action'}),0,1),
2856 'date' => $options{'date'}
2861 $cust_pkg_reason->insert;
2864 =item set_usage USAGE_VALUE_HASHREF
2866 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2867 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2868 upbytes, downbytes, and totalbytes are appropriate keys.
2870 All svc_accts which are part of this package have their values reset.
2875 my ($self, $valueref, %opt) = @_;
2877 foreach my $cust_svc ($self->cust_svc){
2878 my $svc_x = $cust_svc->svc_x;
2879 $svc_x->set_usage($valueref, %opt)
2880 if $svc_x->can("set_usage");
2884 =item recharge USAGE_VALUE_HASHREF
2886 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2887 to which they should be set (see L<FS::svc_acct>). Currently seconds,
2888 upbytes, downbytes, and totalbytes are appropriate keys.
2890 All svc_accts which are part of this package have their values incremented.
2895 my ($self, $valueref) = @_;
2897 foreach my $cust_svc ($self->cust_svc){
2898 my $svc_x = $cust_svc->svc_x;
2899 $svc_x->recharge($valueref)
2900 if $svc_x->can("recharge");
2908 sub order is not OO. Perhaps it should be moved to FS::cust_main and made so?
2910 In sub order, the @pkgparts array (passed by reference) is clobbered.
2912 Also in sub order, no money is adjusted. Once FS::part_pkg defines a standard
2913 method to pass dates to the recur_prog expression, it should do so.
2915 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
2916 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
2917 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
2918 configuration values. Probably need a subroutine which decides what to do
2919 based on whether or not we've fetched the user yet, rather than a hash. See
2920 FS::UID and the TODO.
2922 Now that things are transactional should the check in the insert method be
2927 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
2928 L<FS::pkg_svc>, schema.html from the base documentation