doh, 1.9 fix customer package search (fallout from customer classes) and add "not...
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use vars qw(@ISA $disable_agentcheck $DEBUG);
5 use Carp qw(cluck);
6 use Scalar::Util qw( blessed );
7 use List::Util qw(max);
8 use Tie::IxHash;
9 use MIME::Entity;
10 use FS::UID qw( getotaker dbh );
11 use FS::Misc qw( send_email );
12 use FS::Record qw( qsearch qsearchs );
13 use FS::m2m_Common;
14 use FS::cust_main_Mixin;
15 use FS::cust_svc;
16 use FS::part_pkg;
17 use FS::cust_main;
18 use FS::cust_location;
19 use FS::pkg_svc;
20 use FS::cust_bill_pkg;
21 use FS::cust_pkg_detail;
22 use FS::cust_event;
23 use FS::h_cust_svc;
24 use FS::reg_code;
25 use FS::part_svc;
26 use FS::cust_pkg_reason;
27 use FS::reason;
28 use FS::UI::Web;
29
30 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
31 # setup }
32 # because they load configuration by setting FS::UID::callback (see TODO)
33 use FS::svc_acct;
34 use FS::svc_domain;
35 use FS::svc_www;
36 use FS::svc_forward;
37
38 # for sending cancel emails in sub cancel
39 use FS::Conf;
40
41 @ISA = qw( FS::m2m_Common FS::cust_main_Mixin FS::option_Common FS::Record );
42
43 $DEBUG = 0;
44
45 $disable_agentcheck = 0;
46
47 sub _cache {
48   my $self = shift;
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);
58   }
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};
65   }
66 }
67
68 =head1 NAME
69
70 FS::cust_pkg - Object methods for cust_pkg objects
71
72 =head1 SYNOPSIS
73
74   use FS::cust_pkg;
75
76   $record = new FS::cust_pkg \%hash;
77   $record = new FS::cust_pkg { 'column' => 'value' };
78
79   $error = $record->insert;
80
81   $error = $new_record->replace($old_record);
82
83   $error = $record->delete;
84
85   $error = $record->check;
86
87   $error = $record->cancel;
88
89   $error = $record->suspend;
90
91   $error = $record->unsuspend;
92
93   $part_pkg = $record->part_pkg;
94
95   @labels = $record->labels;
96
97   $seconds = $record->seconds_since($timestamp);
98
99   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
100   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
101
102 =head1 DESCRIPTION
103
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:
106
107 =over 4
108
109 =item pkgnum
110
111 Primary key (assigned automatically for new billing items)
112
113 =item custnum
114
115 Customer (see L<FS::cust_main>)
116
117 =item pkgpart
118
119 Billing item definition (see L<FS::part_pkg>)
120
121 =item locationnum
122
123 Optional link to package location (see L<FS::location>)
124
125 =item start_date
126
127 date
128
129 =item setup
130
131 date
132
133 =item bill
134
135 date (next bill date)
136
137 =item last_bill
138
139 last bill date
140
141 =item adjourn
142
143 date
144
145 =item susp
146
147 date
148
149 =item expire
150
151 date
152
153 =item cancel
154
155 date
156
157 =item otaker
158
159 order taker (assigned automatically if null, see L<FS::UID>)
160
161 =item manual_flag
162
163 If this field is set to 1, disables the automatic
164 unsuspension of this package when using the B<unsuspendauto> config option.
165
166 =item quantity
167
168 If not set, defaults to 1
169
170 =item change_date
171
172 Date of change from previous package
173
174 =item change_pkgnum
175
176 Previous pkgnum
177
178 =item change_pkgpart
179
180 Previous pkgpart
181
182 =item change_locationnum
183
184 Previous locationnum
185
186 =back
187
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.
191
192 =head1 METHODS
193
194 =over 4
195
196 =item new HASHREF
197
198 Create a new billing item.  To add the item to the database, see L<"insert">.
199
200 =cut
201
202 sub table { 'cust_pkg'; }
203 sub cust_linked { $_[0]->cust_main_custnum; } 
204 sub cust_unlinked_msg {
205   my $self = shift;
206   "WARNING: can't find cust_main.custnum ". $self->custnum.
207   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
208 }
209
210 =item insert [ OPTION => VALUE ... ]
211
212 Adds this billing item to the database ("Orders" the item).  If there is an
213 error, returns the error, otherwise returns false.
214
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
217 ignored.
218
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.
224
225 The following options are available:
226
227 =over 4
228
229 =item change
230
231 If set true, supresses any referral credit to a referring customer.
232
233 =item options
234
235 cust_pkg_option records will be created
236
237 =item ticket_subject
238
239 a ticket will be added to this customer with this subject
240
241 =item ticket_queue
242
243 an optional queue name for ticket additions
244
245 =back
246
247 =cut
248
249 sub insert {
250   my( $self, %options ) = @_;
251
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';
258
259   my $oldAutoCommit = $FS::UID::AutoCommit;
260   local $FS::UID::AutoCommit = 0;
261   my $dbh = dbh;
262
263   my $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
264   if ( $error ) {
265     $dbh->rollback if $oldAutoCommit;
266     return $error;
267   }
268
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,
274                     );
275
276   #if ( $self->reg_code ) {
277   #  my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
278   #  $error = $reg_code->delete;
279   #  if ( $error ) {
280   #    $dbh->rollback if $oldAutoCommit;
281   #    return $error;
282   #  }
283   #}
284
285   my $conf = new FS::Conf;
286
287   if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
288     eval '
289       use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
290       use RT;
291     ';
292     die $@ if $@;
293
294     RT::LoadConfig();
295     RT::Init();
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},
303                 MIMEObj => $mime,
304               );
305     $t->AddLink( Type   => 'MemberOf',
306                  Target => 'freeside://freeside/cust_main/'. $self->custnum,
307                );
308   }
309
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',
313     };
314     $error = $queue->insert(
315       'custnum'  => $self->custnum,
316       'template' => 'welcome_letter',
317     );
318
319     if ($error) {
320       warn "can't send welcome letter: $error";
321     }
322
323   }
324
325   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
326   '';
327
328 }
329
330 =item delete
331
332 This method now works but you probably shouldn't use it.
333
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.
336
337 =cut
338
339 #sub delete {
340 #  return "Can't delete cust_pkg records!";
341 #}
342
343 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
344
345 Replaces the OLD_RECORD with this one in the database.  If there is an error,
346 returns the error, otherwise returns false.
347
348 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
349
350 Changing pkgpart may have disasterous effects.  See the order subroutine.
351
352 setup and bill are normally updated by calling the bill method of a customer
353 object (see L<FS::cust_main>).
354
355 suspend is normally updated by the suspend and unsuspend methods.
356
357 cancel is normally updated by the cancel method (and also the order subroutine
358 in some cases).
359
360 Available options are:
361
362 =over 4
363
364 =item reason
365
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.
367
368 =item reason_otaker
369
370 the access_user (see L<FS::access_user>) providing the reason
371
372 =item options
373
374 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
375
376 =back
377
378 =cut
379
380 sub replace {
381   my $new = shift;
382
383   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
384               ? shift
385               : $new->replace_old;
386
387   my $options = 
388     ( ref($_[0]) eq 'HASH' )
389       ? shift
390       : { @_ };
391
392   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
393   return "Can't change otaker!" if $old->otaker ne $new->otaker;
394
395   #allow this *sigh*
396   #return "Can't change setup once it exists!"
397   #  if $old->getfield('setup') &&
398   #     $old->getfield('setup') != $new->getfield('setup');
399
400   #some logic for bill, susp, cancel?
401
402   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
403
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';
410
411   my $oldAutoCommit = $FS::UID::AutoCommit;
412   local $FS::UID::AutoCommit = 0;
413   my $dbh = dbh;
414
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,
420         'action'        => $method,
421         'reason_otaker' => $options->{'reason_otaker'},
422       );
423       if ( $error ) {
424         dbh->rollback if $oldAutoCommit;
425         return "Error inserting cust_pkg_reason: $error";
426       }
427     }
428   }
429
430   #save off and freeze RADIUS attributes for any associated svc_acct records
431   my @svc_acct = ();
432   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
433
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' }
438                      $old->cust_svc;
439
440     $_->snapshot foreach @svc_acct;
441
442   }
443
444   my $error = $new->SUPER::replace($old,
445                                    $options->{options} ? $options->{options} : ()
446                                   );
447   if ( $error ) {
448     $dbh->rollback if $oldAutoCommit;
449     return $error;
450   }
451
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);
457     if ( $s_error ) {
458       $dbh->rollback if $oldAutoCommit;
459       return $s_error;
460     }
461   }
462
463   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
464   '';
465
466 }
467
468 =item check
469
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
472 replace methods.
473
474 =cut
475
476 sub check {
477   my $self = shift;
478
479   $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
480
481   my $error = 
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')
493   ;
494   return $error if $error;
495
496   if ( $self->reg_code ) {
497
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 })
502            ) {
503       return "Unknown registration code";
504     }
505
506   } elsif ( $self->promo_code ) {
507
508     my $promo_part_pkg =
509       qsearchs('part_pkg', {
510         'pkgpart'    => $self->pkgpart,
511         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
512       } );
513     return 'Unknown promotional code' unless $promo_part_pkg;
514
515   } else { 
516
517     unless ( $disable_agentcheck ) {
518       my $agent =
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;
524     }
525
526     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
527     return $error if $error;
528
529   }
530
531   $self->otaker(getotaker) unless $self->otaker;
532   $self->otaker =~ /^(\w{1,32})$/ or return "Illegal otaker";
533   $self->otaker($1);
534
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);
540   }
541
542   $self->SUPER::check;
543 }
544
545 =item cancel [ OPTION => VALUE ... ]
546
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
549 now).
550
551 Available options are:
552
553 =over 4
554
555 =item quiet - can be set true to supress email cancellation notices.
556
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.
558
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.
560
561 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
562
563 =item nobill - can be set true to skip billing if it might otherwise be done.
564
565 =back
566
567 If there is an error, returns the error, otherwise returns false.
568
569 =cut
570
571 sub cancel {
572   my( $self, %options ) = @_;
573   my $error;
574
575   my $conf = new FS::Conf;
576
577   warn "cust_pkg::cancel called with options".
578        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
579     if $DEBUG;
580
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';
587
588   my $oldAutoCommit = $FS::UID::AutoCommit;
589   local $FS::UID::AutoCommit = 0;
590   my $dbh = dbh;
591   
592   my $old = $self->select_for_update;
593
594   if ( $old->get('cancel') || $self->get('cancel') ) {
595     dbh->rollback if $oldAutoCommit;
596     return "";  # no error
597   }
598
599   my $date = $options{date} if $options{date}; # expire/cancel later
600   $date = '' if ($date && $date <= time);      # complain instead?
601
602   #race condition: usage could be ongoing until unprovisioned
603   #resolved by performing a change package instead (which unprovisions) and
604   #later cancelling
605   if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
606       my $copy = $self->new({$self->hash});
607       my $error =
608         $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
609       warn "Error billing during cancel, custnum ".
610         #$self->cust_main->custnum. ": $error"
611         ": $error"
612         if $error;
613   }
614
615
616   my $cancel_time = $options{'time'} || time;
617
618   if ( $options{'reason'} ) {
619     $error = $self->insert_reason( 'reason' => $options{'reason'},
620                                    'action' => $date ? 'expire' : 'cancel',
621                                    'date'   => $date ? $date : $cancel_time,
622                                    'reason_otaker' => $options{'reason_otaker'},
623                                  );
624     if ( $error ) {
625       dbh->rollback if $oldAutoCommit;
626       return "Error inserting cust_pkg_reason: $error";
627     }
628   }
629
630   my %svc;
631   unless ( $date ) {
632     foreach my $cust_svc (
633       #schwartz
634       map  { $_->[0] }
635       sort { $a->[1] <=> $b->[1] }
636       map  { [ $_, $_->svc_x->table_info->{'cancel_weight'} ]; }
637       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
638     ) {
639
640       my $error = $cust_svc->cancel;
641
642       if ( $error ) {
643         $dbh->rollback if $oldAutoCommit;
644         return "Error cancelling cust_svc: $error";
645       }
646     }
647
648     # Add a credit for remaining service
649     my $remaining_value = $self->calc_remain(time=>$cancel_time);
650     if ( $remaining_value > 0 && !$options{'no_credit'} ) {
651       my $error = $self->cust_main->credit(
652         $remaining_value,
653         'Credit for unused time on '. $self->part_pkg->pkg,
654         'reason_type' => $conf->config('cancel_credit_type'),
655       );
656       if ($error) {
657         $dbh->rollback if $oldAutoCommit;
658         return "Error crediting customer \$$remaining_value for unused time on".
659                $self->part_pkg->pkg. ": $error";
660       }
661     }
662   }
663
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 } );
668   if ( $error ) {
669     $dbh->rollback if $oldAutoCommit;
670     return $error;
671   }
672
673   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
674   return '' if $date; #no errors
675
676   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
677   if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) {
678     my $error = send_email(
679       'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
680       'to'      => \@invoicing_list,
681       'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
682       'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
683     );
684     #should this do something on errors?
685   }
686
687   ''; #no errors
688
689 }
690
691 =item cancel_if_expired [ NOW_TIMESTAMP ]
692
693 Cancels this package if its expire date has been reached.
694
695 =cut
696
697 sub cancel_if_expired {
698   my $self = shift;
699   my $time = shift || time;
700   return '' unless $self->expire && $self->expire <= $time;
701   my $error = $self->cancel;
702   if ( $error ) {
703     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
704            $self->custnum. ": $error";
705   }
706   '';
707 }
708
709 =item unexpire
710
711 Cancels any pending expiration (sets the expire field to null).
712
713 If there is an error, returns the error, otherwise returns false.
714
715 =cut
716
717 sub unexpire {
718   my( $self, %options ) = @_;
719   my $error;
720
721   local $SIG{HUP} = 'IGNORE';
722   local $SIG{INT} = 'IGNORE';
723   local $SIG{QUIT} = 'IGNORE';
724   local $SIG{TERM} = 'IGNORE';
725   local $SIG{TSTP} = 'IGNORE';
726   local $SIG{PIPE} = 'IGNORE';
727
728   my $oldAutoCommit = $FS::UID::AutoCommit;
729   local $FS::UID::AutoCommit = 0;
730   my $dbh = dbh;
731
732   my $old = $self->select_for_update;
733
734   my $pkgnum = $old->pkgnum;
735   if ( $old->get('cancel') || $self->get('cancel') ) {
736     dbh->rollback if $oldAutoCommit;
737     return "Can't unexpire cancelled package $pkgnum";
738     # or at least it's pointless
739   }
740
741   unless ( $old->get('expire') && $self->get('expire') ) {
742     dbh->rollback if $oldAutoCommit;
743     return "";  # no error
744   }
745
746   my %hash = $self->hash;
747   $hash{'expire'} = '';
748   my $new = new FS::cust_pkg ( \%hash );
749   $error = $new->replace( $self, options => { $self->options } );
750   if ( $error ) {
751     $dbh->rollback if $oldAutoCommit;
752     return $error;
753   }
754
755   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
756
757   ''; #no errors
758
759 }
760
761 =item suspend [ OPTION => VALUE ... ]
762
763 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
764 package, then suspends the package itself (sets the susp field to now).
765
766 Available options are:
767
768 =over 4
769
770 =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
772 =item date - can be set to a unix style timestamp to specify when to suspend (adjourn)
773
774 =back
775
776 If there is an error, returns the error, otherwise returns false.
777
778 =cut
779
780 sub suspend {
781   my( $self, %options ) = @_;
782   my $error;
783
784   local $SIG{HUP} = 'IGNORE';
785   local $SIG{INT} = 'IGNORE';
786   local $SIG{QUIT} = 'IGNORE'; 
787   local $SIG{TERM} = 'IGNORE';
788   local $SIG{TSTP} = 'IGNORE';
789   local $SIG{PIPE} = 'IGNORE';
790
791   my $oldAutoCommit = $FS::UID::AutoCommit;
792   local $FS::UID::AutoCommit = 0;
793   my $dbh = dbh;
794
795   my $old = $self->select_for_update;
796
797   my $pkgnum = $old->pkgnum;
798   if ( $old->get('cancel') || $self->get('cancel') ) {
799     dbh->rollback if $oldAutoCommit;
800     return "Can't suspend cancelled package $pkgnum";
801   }
802
803   if ( $old->get('susp') || $self->get('susp') ) {
804     dbh->rollback if $oldAutoCommit;
805     return "";  # no error                     # complain on adjourn?
806   }
807
808   my $date = $options{date} if $options{date}; # adjourn/suspend later
809   $date = '' if ($date && $date <= time);      # complain instead?
810
811   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
812     dbh->rollback if $oldAutoCommit;
813     return "Package $pkgnum expires before it would be suspended.";
814   }
815
816   my $suspend_time = $options{'time'} || time;
817
818   if ( $options{'reason'} ) {
819     $error = $self->insert_reason( 'reason' => $options{'reason'},
820                                    'action' => $date ? 'adjourn' : 'suspend',
821                                    'date'   => $date ? $date : $suspend_time,
822                                    'reason_otaker' => $options{'reason_otaker'},
823                                  );
824     if ( $error ) {
825       dbh->rollback if $oldAutoCommit;
826       return "Error inserting cust_pkg_reason: $error";
827     }
828   }
829
830   unless ( $date ) {
831
832     my @labels = ();
833
834     foreach my $cust_svc (
835       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
836     ) {
837       my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
838
839       $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
840         $dbh->rollback if $oldAutoCommit;
841         return "Illegal svcdb value in part_svc!";
842       };
843       my $svcdb = $1;
844       require "FS/$svcdb.pm";
845
846       my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
847       if ($svc) {
848         $error = $svc->suspend;
849         if ( $error ) {
850           $dbh->rollback if $oldAutoCommit;
851           return $error;
852         }
853         my( $label, $value ) = $cust_svc->label;
854         push @labels, "$label: $value";
855       }
856     }
857
858     my $conf = new FS::Conf;
859     if ( $conf->config('suspend_email_admin') ) {
860  
861       my $error = send_email(
862         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
863                                    #invoice_from ??? well as good as any
864         'to'      => $conf->config('suspend_email_admin'),
865         'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
866         'body'    => [
867           "This is an automatic message from your Freeside installation\n",
868           "informing you that the following customer package has been suspended:\n",
869           "\n",
870           'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
871           'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
872           ( map { "Service : $_\n" } @labels ),
873         ],
874       );
875
876       if ( $error ) {
877         warn "WARNING: can't send suspension admin email (suspending anyway): ".
878              "$error\n";
879       }
880
881     }
882
883   }
884
885   my %hash = $self->hash;
886   if ( $date ) {
887     $hash{'adjourn'} = $date;
888   } else {
889     $hash{'susp'} = $suspend_time;
890   }
891   my $new = new FS::cust_pkg ( \%hash );
892   $error = $new->replace( $self, options => { $self->options } );
893   if ( $error ) {
894     $dbh->rollback if $oldAutoCommit;
895     return $error;
896   }
897
898   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
899
900   ''; #no errors
901 }
902
903 =item unsuspend [ OPTION => VALUE ... ]
904
905 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
906 package, then unsuspends the package itself (clears the susp field and the
907 adjourn field if it is in the past).
908
909 Available options are:
910
911 =over 4
912
913 =item adjust_next_bill
914
915 Can be set true to adjust the next bill date forward by
916 the amount of time the account was inactive.  This was set true by default
917 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
918 explicitly requested.  Price plans for which this makes sense (anniversary-date
919 based than prorate or subscription) could have an option to enable this
920 behaviour?
921
922 =back
923
924 If there is an error, returns the error, otherwise returns false.
925
926 =cut
927
928 sub unsuspend {
929   my( $self, %opt ) = @_;
930   my $error;
931
932   local $SIG{HUP} = 'IGNORE';
933   local $SIG{INT} = 'IGNORE';
934   local $SIG{QUIT} = 'IGNORE'; 
935   local $SIG{TERM} = 'IGNORE';
936   local $SIG{TSTP} = 'IGNORE';
937   local $SIG{PIPE} = 'IGNORE';
938
939   my $oldAutoCommit = $FS::UID::AutoCommit;
940   local $FS::UID::AutoCommit = 0;
941   my $dbh = dbh;
942
943   my $old = $self->select_for_update;
944
945   my $pkgnum = $old->pkgnum;
946   if ( $old->get('cancel') || $self->get('cancel') ) {
947     dbh->rollback if $oldAutoCommit;
948     return "Can't unsuspend cancelled package $pkgnum";
949   }
950
951   unless ( $old->get('susp') && $self->get('susp') ) {
952     dbh->rollback if $oldAutoCommit;
953     return "";  # no error                     # complain instead?
954   }
955
956   foreach my $cust_svc (
957     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
958   ) {
959     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
960
961     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
962       $dbh->rollback if $oldAutoCommit;
963       return "Illegal svcdb value in part_svc!";
964     };
965     my $svcdb = $1;
966     require "FS/$svcdb.pm";
967
968     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
969     if ($svc) {
970       $error = $svc->unsuspend;
971       if ( $error ) {
972         $dbh->rollback if $oldAutoCommit;
973         return $error;
974       }
975     }
976
977   }
978
979   my %hash = $self->hash;
980   my $inactive = time - $hash{'susp'};
981
982   my $conf = new FS::Conf;
983
984   $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
985     if ( $opt{'adjust_next_bill'}
986          || $conf->exists('unsuspend-always_adjust_next_bill_date') )
987     && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
988
989   $hash{'susp'} = '';
990   $hash{'adjourn'} = '' if $hash{'adjourn'} < time;
991   my $new = new FS::cust_pkg ( \%hash );
992   $error = $new->replace( $self, options => { $self->options } );
993   if ( $error ) {
994     $dbh->rollback if $oldAutoCommit;
995     return $error;
996   }
997
998   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
999
1000   ''; #no errors
1001 }
1002
1003 =item unadjourn
1004
1005 Cancels any pending suspension (sets the adjourn field to null).
1006
1007 If there is an error, returns the error, otherwise returns false.
1008
1009 =cut
1010
1011 sub unadjourn {
1012   my( $self, %options ) = @_;
1013   my $error;
1014
1015   local $SIG{HUP} = 'IGNORE';
1016   local $SIG{INT} = 'IGNORE';
1017   local $SIG{QUIT} = 'IGNORE'; 
1018   local $SIG{TERM} = 'IGNORE';
1019   local $SIG{TSTP} = 'IGNORE';
1020   local $SIG{PIPE} = 'IGNORE';
1021
1022   my $oldAutoCommit = $FS::UID::AutoCommit;
1023   local $FS::UID::AutoCommit = 0;
1024   my $dbh = dbh;
1025
1026   my $old = $self->select_for_update;
1027
1028   my $pkgnum = $old->pkgnum;
1029   if ( $old->get('cancel') || $self->get('cancel') ) {
1030     dbh->rollback if $oldAutoCommit;
1031     return "Can't unadjourn cancelled package $pkgnum";
1032     # or at least it's pointless
1033   }
1034
1035   if ( $old->get('susp') || $self->get('susp') ) {
1036     dbh->rollback if $oldAutoCommit;
1037     return "Can't unadjourn suspended package $pkgnum";
1038     # perhaps this is arbitrary
1039   }
1040
1041   unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1042     dbh->rollback if $oldAutoCommit;
1043     return "";  # no error
1044   }
1045
1046   my %hash = $self->hash;
1047   $hash{'adjourn'} = '';
1048   my $new = new FS::cust_pkg ( \%hash );
1049   $error = $new->replace( $self, options => { $self->options } );
1050   if ( $error ) {
1051     $dbh->rollback if $oldAutoCommit;
1052     return $error;
1053   }
1054
1055   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1056
1057   ''; #no errors
1058
1059 }
1060
1061
1062 =item change HASHREF | OPTION => VALUE ... 
1063
1064 Changes this package: cancels it and creates a new one, with a different
1065 pkgpart or locationnum or both.  All services are transferred to the new
1066 package (no change will be made if this is not possible).
1067
1068 Options may be passed as a list of key/value pairs or as a hash reference.
1069 Options are:
1070
1071 =over 4
1072
1073 =item locaitonnum
1074
1075 New locationnum, to change the location for this package.
1076
1077 =item cust_location
1078
1079 New FS::cust_location object, to create a new location and assign it
1080 to this package.
1081
1082 =item pkgpart
1083
1084 New pkgpart (see L<FS::part_pkg>).
1085
1086 =item refnum
1087
1088 New refnum (see L<FS::part_referral>).
1089
1090 =back
1091
1092 At least one option must be specified (otherwise, what's the point?)
1093
1094 Returns either the new FS::cust_pkg object or a scalar error.
1095
1096 For example:
1097
1098   my $err_or_new_cust_pkg = $old_cust_pkg->change
1099
1100 =cut
1101
1102 #some false laziness w/order
1103 sub change {
1104   my $self = shift;
1105   my $opt = ref($_[0]) ? shift : { @_ };
1106
1107 #  my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1108 #    
1109
1110   my $conf = new FS::Conf;
1111
1112   # Transactionize this whole mess
1113   local $SIG{HUP} = 'IGNORE';
1114   local $SIG{INT} = 'IGNORE'; 
1115   local $SIG{QUIT} = 'IGNORE';
1116   local $SIG{TERM} = 'IGNORE';
1117   local $SIG{TSTP} = 'IGNORE'; 
1118   local $SIG{PIPE} = 'IGNORE'; 
1119
1120   my $oldAutoCommit = $FS::UID::AutoCommit;
1121   local $FS::UID::AutoCommit = 0;
1122   my $dbh = dbh;
1123
1124   my $error;
1125
1126   my %hash = (); 
1127
1128   my $time = time;
1129
1130   #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1131     
1132   #$hash{$_} = $self->$_() foreach qw( setup );
1133
1134   $hash{'setup'} = $time if $self->setup;
1135
1136   $hash{'change_date'} = $time;
1137   $hash{"change_$_"}  = $self->$_()
1138     foreach qw( pkgnum pkgpart locationnum );
1139
1140   if ( $opt->{'cust_location'} &&
1141        ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1142     $error = $opt->{'cust_location'}->insert;
1143     if ( $error ) {
1144       $dbh->rollback if $oldAutoCommit;
1145       return "inserting cust_location (transaction rolled back): $error";
1146     }
1147     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1148   }
1149
1150   # Create the new package.
1151   my $cust_pkg = new FS::cust_pkg {
1152     custnum      => $self->custnum,
1153     pkgpart      => ( $opt->{'pkgpart'}     || $self->pkgpart      ),
1154     refnum       => ( $opt->{'refnum'}      || $self->refnum       ),
1155     locationnum  => ( $opt->{'locationnum'} || $self->locationnum  ),
1156     %hash,
1157   };
1158
1159   $error = $cust_pkg->insert( 'change' => 1 );
1160   if ($error) {
1161     $dbh->rollback if $oldAutoCommit;
1162     return $error;
1163   }
1164
1165   # Transfer services and cancel old package.
1166
1167   $error = $self->transfer($cust_pkg);
1168   if ($error and $error == 0) {
1169     # $old_pkg->transfer failed.
1170     $dbh->rollback if $oldAutoCommit;
1171     return $error;
1172   }
1173
1174   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1175     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1176     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1177     if ($error and $error == 0) {
1178       # $old_pkg->transfer failed.
1179       $dbh->rollback if $oldAutoCommit;
1180       return $error;
1181     }
1182   }
1183
1184   if ($error > 0) {
1185     # Transfers were successful, but we still had services left on the old
1186     # package.  We can't change the package under this circumstances, so abort.
1187     $dbh->rollback if $oldAutoCommit;
1188     return "Unable to transfer all services from package ". $self->pkgnum;
1189   }
1190
1191   #reset usage if changing pkgpart
1192   # AND usage rollover is off (otherwise adds twice, now and at package bill)
1193   if ($self->pkgpart != $cust_pkg->pkgpart) {
1194     my $part_pkg = $cust_pkg->part_pkg;
1195     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1196                                                  ? ()
1197                                                  : ( 'null' => 1 )
1198                                    )
1199       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover');
1200
1201     if ($error) {
1202       $dbh->rollback if $oldAutoCommit;
1203       return "Error setting usage values: $error";
1204     }
1205   }
1206
1207   #Good to go, cancel old package.
1208   $error = $self->cancel( quiet=>1 );
1209   if ($error) {
1210     $dbh->rollback if $oldAutoCommit;
1211     return $error;
1212   }
1213
1214   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
1215     #$self->cust_main
1216     my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
1217     if ( $error ) {
1218       $dbh->rollback if $oldAutoCommit;
1219       return $error;
1220     }
1221   }
1222
1223   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1224
1225   $cust_pkg;
1226
1227 }
1228
1229 =item last_bill
1230
1231 Returns the last bill date, or if there is no last bill date, the setup date.
1232 Useful for billing metered services.
1233
1234 =cut
1235
1236 sub last_bill {
1237   my $self = shift;
1238   return $self->setfield('last_bill', $_[0]) if @_;
1239   return $self->getfield('last_bill') if $self->getfield('last_bill');
1240   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1241                                                   'edate'  => $self->bill,  } );
1242   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1243 }
1244
1245 =item last_cust_pkg_reason ACTION
1246
1247 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1248 Returns false if there is no reason or the package is not currenly ACTION'd
1249 ACTION is one of adjourn, susp, cancel, or expire.
1250
1251 =cut
1252
1253 sub last_cust_pkg_reason {
1254   my ( $self, $action ) = ( shift, shift );
1255   my $date = $self->get($action);
1256   qsearchs( {
1257               'table' => 'cust_pkg_reason',
1258               'hashref' => { 'pkgnum' => $self->pkgnum,
1259                              'action' => substr(uc($action), 0, 1),
1260                              'date'   => $date,
1261                            },
1262               'order_by' => 'ORDER BY num DESC LIMIT 1',
1263            } );
1264 }
1265
1266 =item last_reason ACTION
1267
1268 Returns the most recent ACTION FS::reason associated with the package.
1269 Returns false if there is no reason or the package is not currenly ACTION'd
1270 ACTION is one of adjourn, susp, cancel, or expire.
1271
1272 =cut
1273
1274 sub last_reason {
1275   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1276   $cust_pkg_reason->reason
1277     if $cust_pkg_reason;
1278 }
1279
1280 =item part_pkg
1281
1282 Returns the definition for this billing item, as an FS::part_pkg object (see
1283 L<FS::part_pkg>).
1284
1285 =cut
1286
1287 sub part_pkg {
1288   my $self = shift;
1289   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1290   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1291   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1292 }
1293
1294 =item old_cust_pkg
1295
1296 Returns the cancelled package this package was changed from, if any.
1297
1298 =cut
1299
1300 sub old_cust_pkg {
1301   my $self = shift;
1302   return '' unless $self->change_pkgnum;
1303   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1304 }
1305
1306 =item calc_setup
1307
1308 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1309 item.
1310
1311 =cut
1312
1313 sub calc_setup {
1314   my $self = shift;
1315   $self->part_pkg->calc_setup($self, @_);
1316 }
1317
1318 =item calc_recur
1319
1320 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1321 item.
1322
1323 =cut
1324
1325 sub calc_recur {
1326   my $self = shift;
1327   $self->part_pkg->calc_recur($self, @_);
1328 }
1329
1330 =item calc_remain
1331
1332 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1333 billing item.
1334
1335 =cut
1336
1337 sub calc_remain {
1338   my $self = shift;
1339   $self->part_pkg->calc_remain($self, @_);
1340 }
1341
1342 =item calc_cancel
1343
1344 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1345 billing item.
1346
1347 =cut
1348
1349 sub calc_cancel {
1350   my $self = shift;
1351   $self->part_pkg->calc_cancel($self, @_);
1352 }
1353
1354 =item cust_bill_pkg
1355
1356 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1357
1358 =cut
1359
1360 sub cust_bill_pkg {
1361   my $self = shift;
1362   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1363 }
1364
1365 =item cust_pkg_detail [ DETAILTYPE ]
1366
1367 Returns any customer package details for this package (see
1368 L<FS::cust_pkg_detail>).
1369
1370 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1371
1372 =cut
1373
1374 sub cust_pkg_detail {
1375   my $self = shift;
1376   my %hash = ( 'pkgnum' => $self->pkgnum );
1377   $hash{detailtype} = shift if @_;
1378   qsearch({
1379     'table'    => 'cust_pkg_detail',
1380     'hashref'  => \%hash,
1381     'order_by' => 'ORDER BY weight, pkgdetailnum',
1382   });
1383 }
1384
1385 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1386
1387 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1388
1389 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1390
1391 If there is an error, returns the error, otherwise returns false.
1392
1393 =cut
1394
1395 sub set_cust_pkg_detail {
1396   my( $self, $detailtype, @details ) = @_;
1397
1398   local $SIG{HUP} = 'IGNORE';
1399   local $SIG{INT} = 'IGNORE';
1400   local $SIG{QUIT} = 'IGNORE';
1401   local $SIG{TERM} = 'IGNORE';
1402   local $SIG{TSTP} = 'IGNORE';
1403   local $SIG{PIPE} = 'IGNORE';
1404
1405   my $oldAutoCommit = $FS::UID::AutoCommit;
1406   local $FS::UID::AutoCommit = 0;
1407   my $dbh = dbh;
1408
1409   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1410     my $error = $current->delete;
1411     if ( $error ) {
1412       $dbh->rollback if $oldAutoCommit;
1413       return "error removing old detail: $error";
1414     }
1415   }
1416
1417   foreach my $detail ( @details ) {
1418     my $cust_pkg_detail = new FS::cust_pkg_detail {
1419       'pkgnum'     => $self->pkgnum,
1420       'detailtype' => $detailtype,
1421       'detail'     => $detail,
1422     };
1423     my $error = $cust_pkg_detail->insert;
1424     if ( $error ) {
1425       $dbh->rollback if $oldAutoCommit;
1426       return "error adding new detail: $error";
1427     }
1428
1429   }
1430
1431   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1432   '';
1433
1434 }
1435
1436 =item cust_event
1437
1438 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
1439
1440 =cut
1441
1442 #false laziness w/cust_bill.pm
1443 sub cust_event {
1444   my $self = shift;
1445   qsearch({
1446     'table'     => 'cust_event',
1447     'addl_from' => 'JOIN part_event USING ( eventpart )',
1448     'hashref'   => { 'tablenum' => $self->pkgnum },
1449     'extra_sql' => " AND eventtable = 'cust_pkg' ",
1450   });
1451 }
1452
1453 =item num_cust_event
1454
1455 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
1456
1457 =cut
1458
1459 #false laziness w/cust_bill.pm
1460 sub num_cust_event {
1461   my $self = shift;
1462   my $sql =
1463     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
1464     "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
1465   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
1466   $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
1467   $sth->fetchrow_arrayref->[0];
1468 }
1469
1470 =item cust_svc [ SVCPART ]
1471
1472 Returns the services for this package, as FS::cust_svc objects (see
1473 L<FS::cust_svc>).  If a svcpart is specified, return only the matching
1474 services.
1475
1476 =cut
1477
1478 sub cust_svc {
1479   my $self = shift;
1480
1481   return () unless $self->num_cust_svc(@_);
1482
1483   if ( @_ ) {
1484     return qsearch( 'cust_svc', { 'pkgnum'  => $self->pkgnum,
1485                                   'svcpart' => shift,          } );
1486   }
1487
1488   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
1489
1490   #if ( $self->{'_svcnum'} ) {
1491   #  values %{ $self->{'_svcnum'}->cache };
1492   #} else {
1493     $self->_sort_cust_svc(
1494       [ qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ]
1495     );
1496   #}
1497
1498 }
1499
1500 =item overlimit [ SVCPART ]
1501
1502 Returns the services for this package which have exceeded their
1503 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
1504 is specified, return only the matching services.
1505
1506 =cut
1507
1508 sub overlimit {
1509   my $self = shift;
1510   return () unless $self->num_cust_svc(@_);
1511   grep { $_->overlimit } $self->cust_svc(@_);
1512 }
1513
1514 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] 
1515
1516 Returns historical services for this package created before END TIMESTAMP and
1517 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
1518 (see L<FS::h_cust_svc>).
1519
1520 =cut
1521
1522 sub h_cust_svc {
1523   my $self = shift;
1524
1525   $self->_sort_cust_svc(
1526     [ qsearch( 'h_cust_svc',
1527                { 'pkgnum' => $self->pkgnum, },
1528                FS::h_cust_svc->sql_h_search(@_),
1529              )
1530     ]
1531   );
1532 }
1533
1534 sub _sort_cust_svc {
1535   my( $self, $arrayref ) = @_;
1536
1537   map  { $_->[0] }
1538   sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
1539   map {
1540         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
1541                                              'svcpart' => $_->svcpart     } );
1542         [ $_,
1543           $pkg_svc ? $pkg_svc->primary_svc : '',
1544           $pkg_svc ? $pkg_svc->quantity : 0,
1545         ];
1546       }
1547   @$arrayref;
1548
1549 }
1550
1551 =item num_cust_svc [ SVCPART ]
1552
1553 Returns the number of provisioned services for this package.  If a svcpart is
1554 specified, counts only the matching services.
1555
1556 =cut
1557
1558 sub num_cust_svc {
1559   my $self = shift;
1560
1561   return $self->{'_num_cust_svc'}
1562     if !scalar(@_)
1563        && exists($self->{'_num_cust_svc'})
1564        && $self->{'_num_cust_svc'} =~ /\d/;
1565
1566   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
1567     if $DEBUG > 2;
1568
1569   my $sql = 'SELECT COUNT(*) FROM cust_svc WHERE pkgnum = ?';
1570   $sql .= ' AND svcpart = ?' if @_;
1571
1572   my $sth = dbh->prepare($sql)     or die  dbh->errstr;
1573   $sth->execute($self->pkgnum, @_) or die $sth->errstr;
1574   $sth->fetchrow_arrayref->[0];
1575 }
1576
1577 =item available_part_svc 
1578
1579 Returns a list of FS::part_svc objects representing services included in this
1580 package but not yet provisioned.  Each FS::part_svc object also has an extra
1581 field, I<num_avail>, which specifies the number of available services.
1582
1583 =cut
1584
1585 sub available_part_svc {
1586   my $self = shift;
1587   grep { $_->num_avail > 0 }
1588     map {
1589           my $part_svc = $_->part_svc;
1590           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
1591             $_->quantity - $self->num_cust_svc($_->svcpart);
1592           $part_svc;
1593         }
1594       $self->part_pkg->pkg_svc;
1595 }
1596
1597 =item part_svc
1598
1599 Returns a list of FS::part_svc objects representing provisioned and available
1600 services included in this package.  Each FS::part_svc object also has the
1601 following extra fields:
1602
1603 =over 4
1604
1605 =item num_cust_svc  (count)
1606
1607 =item num_avail     (quantity - count)
1608
1609 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
1610
1611 svcnum
1612 label -> ($cust_svc->label)[1]
1613
1614 =back
1615
1616 =cut
1617
1618 sub part_svc {
1619   my $self = shift;
1620
1621   #XXX some sort of sort order besides numeric by svcpart...
1622   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
1623     my $pkg_svc = $_;
1624     my $part_svc = $pkg_svc->part_svc;
1625     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1626     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
1627     $part_svc->{'Hash'}{'num_avail'}    =
1628       max( 0, $pkg_svc->quantity - $num_cust_svc );
1629     $part_svc->{'Hash'}{'cust_pkg_svc'} =
1630       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1631     $part_svc;
1632   } $self->part_pkg->pkg_svc;
1633
1634   #extras
1635   push @part_svc, map {
1636     my $part_svc = $_;
1637     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
1638     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
1639     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
1640     $part_svc->{'Hash'}{'cust_pkg_svc'} =
1641       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
1642     $part_svc;
1643   } $self->extra_part_svc;
1644
1645   @part_svc;
1646
1647 }
1648
1649 =item extra_part_svc
1650
1651 Returns a list of FS::part_svc objects corresponding to services in this
1652 package which are still provisioned but not (any longer) available in the
1653 package definition.
1654
1655 =cut
1656
1657 sub extra_part_svc {
1658   my $self = shift;
1659
1660   my $pkgnum  = $self->pkgnum;
1661   my $pkgpart = $self->pkgpart;
1662
1663 #  qsearch( {
1664 #    'table'     => 'part_svc',
1665 #    'hashref'   => {},
1666 #    'extra_sql' =>
1667 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
1668 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
1669 #                       AND pkg_svc.pkgpart = ?
1670 #                       AND quantity > 0 
1671 #                 )
1672 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
1673 #                       LEFT JOIN cust_pkg USING ( pkgnum )
1674 #                     WHERE cust_svc.svcpart = part_svc.svcpart
1675 #                       AND pkgnum = ?
1676 #                 )",
1677 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1678 #  } );
1679
1680 #seems to benchmark slightly faster...
1681   qsearch( {
1682     'select'      => 'DISTINCT ON (svcpart) part_svc.*',
1683     'table'       => 'part_svc',
1684     'addl_from'   =>
1685       'LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
1686                                AND pkg_svc.pkgpart   = ?
1687                                AND quantity > 0
1688                              )
1689        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
1690        LEFT JOIN cust_pkg USING ( pkgnum )
1691       ',
1692     'hashref'     => {},
1693     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
1694     'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
1695   } );
1696 }
1697
1698 =item status
1699
1700 Returns a short status string for this package, currently:
1701
1702 =over 4
1703
1704 =item not yet billed
1705
1706 =item one-time charge
1707
1708 =item active
1709
1710 =item suspended
1711
1712 =item cancelled
1713
1714 =back
1715
1716 =cut
1717
1718 sub status {
1719   my $self = shift;
1720
1721   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
1722
1723   return 'cancelled' if $self->get('cancel');
1724   return 'suspended' if $self->susp;
1725   return 'not yet billed' unless $self->setup;
1726   return 'one-time charge' if $freq =~ /^(0|$)/;
1727   return 'active';
1728 }
1729
1730 =item statuses
1731
1732 Class method that returns the list of possible status strings for packages
1733 (see L<the status method|/status>).  For example:
1734
1735   @statuses = FS::cust_pkg->statuses();
1736
1737 =cut
1738
1739 tie my %statuscolor, 'Tie::IxHash', 
1740   'not yet billed'  => '000000',
1741   'one-time charge' => '000000',
1742   'active'          => '00CC00',
1743   'suspended'       => 'FF9900',
1744   'cancelled'       => 'FF0000',
1745 ;
1746
1747 sub statuses {
1748   my $self = shift; #could be class...
1749   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
1750   #                                    # mayble split btw one-time vs. recur
1751     keys %statuscolor;
1752 }
1753
1754 =item statuscolor
1755
1756 Returns a hex triplet color string for this package's status.
1757
1758 =cut
1759
1760 sub statuscolor {
1761   my $self = shift;
1762   $statuscolor{$self->status};
1763 }
1764
1765 =item pkg_label
1766
1767 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
1768 "pkg-comment" depending on user preference).
1769
1770 =cut
1771
1772 sub pkg_label {
1773   my $self = shift;
1774   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
1775   $label = $self->pkgnum. ": $label"
1776     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
1777   $label;
1778 }
1779
1780 =item pkg_label_long
1781
1782 Returns a long label for this package, adding the primary service's label to
1783 pkg_label.
1784
1785 =cut
1786
1787 sub pkg_label_long {
1788   my $self = shift;
1789   my $label = $self->pkg_label;
1790   my $cust_svc = $self->primary_cust_svc;
1791   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
1792   $label;
1793 }
1794
1795 =item primary_cust_svc
1796
1797 Returns a primary service (as FS::cust_svc object) if one can be identified.
1798
1799 =cut
1800
1801 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
1802
1803 sub primary_cust_svc {
1804   my $self = shift;
1805
1806   my @cust_svc = $self->cust_svc;
1807
1808   return '' unless @cust_svc; #no serivces - irrelevant then
1809   
1810   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
1811
1812   # primary service as specified in the package definition
1813   # or exactly one service definition with quantity one
1814   my $svcpart = $self->part_pkg->svcpart;
1815   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
1816   return $cust_svc[0] if scalar(@cust_svc) == 1;
1817
1818   #couldn't identify one thing..
1819   return '';
1820 }
1821
1822 =item labels
1823
1824 Returns a list of lists, calling the label method for all services
1825 (see L<FS::cust_svc>) of this billing item.
1826
1827 =cut
1828
1829 sub labels {
1830   my $self = shift;
1831   map { [ $_->label ] } $self->cust_svc;
1832 }
1833
1834 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] 
1835
1836 Like the labels method, but returns historical information on services that
1837 were active as of END_TIMESTAMP and (optionally) not cancelled before
1838 START_TIMESTAMP.
1839
1840 Returns a list of lists, calling the label method for all (historical) services
1841 (see L<FS::h_cust_svc>) of this billing item.
1842
1843 =cut
1844
1845 sub h_labels {
1846   my $self = shift;
1847   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
1848 }
1849
1850 =item labels_short
1851
1852 Like labels, except returns a simple flat list, and shortens long
1853 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1854 identical services to one line that lists the service label and the number of
1855 individual services rather than individual items.
1856
1857 =cut
1858
1859 sub labels_short {
1860   shift->_labels_short( 'labels', @_ );
1861 }
1862
1863 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
1864
1865 Like h_labels, except returns a simple flat list, and shortens long
1866 (currently >5 or the cust_bill-max_same_services configuration value) lists of
1867 identical services to one line that lists the service label and the number of
1868 individual services rather than individual items.
1869
1870 =cut
1871
1872 sub h_labels_short {
1873   shift->_labels_short( 'h_labels', @_ );
1874 }
1875
1876 sub _labels_short {
1877   my( $self, $method ) = ( shift, shift );
1878
1879   my $conf = new FS::Conf;
1880   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
1881
1882   my %labels;
1883   #tie %labels, 'Tie::IxHash';
1884   push @{ $labels{$_->[0]} }, $_->[1]
1885     foreach $self->h_labels(@_);
1886   my @labels;
1887   foreach my $label ( keys %labels ) {
1888     my %seen = ();
1889     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
1890     my $num = scalar(@values);
1891     if ( $num > $max_same_services ) {
1892       push @labels, "$label ($num)";
1893     } else {
1894       if ( $conf->exists('cust_bill-consolidate_services') ) {
1895         # push @labels, "$label: ". join(', ', @values);
1896         while ( @values ) {
1897           my $detail = "$label: ";
1898           $detail .= shift(@values). ', '
1899             while @values && length($detail.$values[0]) < 78;
1900           $detail =~ s/, $//;
1901           push @labels, $detail;
1902         }
1903       } else {
1904         push @labels, map { "$label: $_" } @values;
1905       }
1906     }
1907   }
1908
1909  @labels;
1910
1911 }
1912
1913 =item cust_main
1914
1915 Returns the parent customer object (see L<FS::cust_main>).
1916
1917 =cut
1918
1919 sub cust_main {
1920   my $self = shift;
1921   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
1922 }
1923
1924 =item cust_location
1925
1926 Returns the location object, if any (see L<FS::cust_location>).
1927
1928 =cut
1929
1930 sub cust_location {
1931   my $self = shift;
1932   return '' unless $self->locationnum;
1933   qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } );
1934 }
1935
1936 =item cust_location_or_main
1937
1938 If this package is associated with a location, returns the locaiton (see
1939 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
1940
1941 =cut
1942
1943 sub cust_location_or_main {
1944   my $self = shift;
1945   $self->cust_location || $self->cust_main;
1946 }
1947
1948 =item seconds_since TIMESTAMP
1949
1950 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
1951 package have been online since TIMESTAMP, according to the session monitor.
1952
1953 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
1954 L<Time::Local> and L<Date::Parse> for conversion functions.
1955
1956 =cut
1957
1958 sub seconds_since {
1959   my($self, $since) = @_;
1960   my $seconds = 0;
1961
1962   foreach my $cust_svc (
1963     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
1964   ) {
1965     $seconds += $cust_svc->seconds_since($since);
1966   }
1967
1968   $seconds;
1969
1970 }
1971
1972 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
1973
1974 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
1975 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
1976 (exclusive).
1977
1978 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
1979 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
1980 functions.
1981
1982
1983 =cut
1984
1985 sub seconds_since_sqlradacct {
1986   my($self, $start, $end) = @_;
1987
1988   my $seconds = 0;
1989
1990   foreach my $cust_svc (
1991     grep {
1992       my $part_svc = $_->part_svc;
1993       $part_svc->svcdb eq 'svc_acct'
1994         && scalar($part_svc->part_export('sqlradius'));
1995     } $self->cust_svc
1996   ) {
1997     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
1998   }
1999
2000   $seconds;
2001
2002 }
2003
2004 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2005
2006 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2007 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2008 TIMESTAMP_END
2009 (exclusive).
2010
2011 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2012 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2013 functions.
2014
2015 =cut
2016
2017 sub attribute_since_sqlradacct {
2018   my($self, $start, $end, $attrib) = @_;
2019
2020   my $sum = 0;
2021
2022   foreach my $cust_svc (
2023     grep {
2024       my $part_svc = $_->part_svc;
2025       $part_svc->svcdb eq 'svc_acct'
2026         && scalar($part_svc->part_export('sqlradius'));
2027     } $self->cust_svc
2028   ) {
2029     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2030   }
2031
2032   $sum;
2033
2034 }
2035
2036 =item quantity
2037
2038 =cut
2039
2040 sub quantity {
2041   my( $self, $value ) = @_;
2042   if ( defined($value) ) {
2043     $self->setfield('quantity', $value);
2044   }
2045   $self->getfield('quantity') || 1;
2046 }
2047
2048 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2049
2050 Transfers as many services as possible from this package to another package.
2051
2052 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2053 object.  The destination package must already exist.
2054
2055 Services are moved only if the destination allows services with the correct
2056 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
2057 this option with caution!  No provision is made for export differences
2058 between the old and new service definitions.  Probably only should be used
2059 when your exports for all service definitions of a given svcdb are identical.
2060 (attempt a transfer without it first, to move all possible svcpart-matching
2061 services)
2062
2063 Any services that can't be moved remain in the original package.
2064
2065 Returns an error, if there is one; otherwise, returns the number of services 
2066 that couldn't be moved.
2067
2068 =cut
2069
2070 sub transfer {
2071   my ($self, $dest_pkgnum, %opt) = @_;
2072
2073   my $remaining = 0;
2074   my $dest;
2075   my %target;
2076
2077   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2078     $dest = $dest_pkgnum;
2079     $dest_pkgnum = $dest->pkgnum;
2080   } else {
2081     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2082   }
2083
2084   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2085
2086   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2087     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2088   }
2089
2090   foreach my $cust_svc ($dest->cust_svc) {
2091     $target{$cust_svc->svcpart}--;
2092   }
2093
2094   my %svcpart2svcparts = ();
2095   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2096     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2097     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2098       next if exists $svcpart2svcparts{$svcpart};
2099       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2100       $svcpart2svcparts{$svcpart} = [
2101         map  { $_->[0] }
2102         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
2103         map {
2104               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2105                                                    'svcpart' => $_          } );
2106               [ $_,
2107                 $pkg_svc ? $pkg_svc->primary_svc : '',
2108                 $pkg_svc ? $pkg_svc->quantity : 0,
2109               ];
2110             }
2111
2112         grep { $_ != $svcpart }
2113         map  { $_->svcpart }
2114         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2115       ];
2116       warn "alternates for svcpart $svcpart: ".
2117            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2118         if $DEBUG;
2119     }
2120   }
2121
2122   foreach my $cust_svc ($self->cust_svc) {
2123     if($target{$cust_svc->svcpart} > 0) {
2124       $target{$cust_svc->svcpart}--;
2125       my $new = new FS::cust_svc { $cust_svc->hash };
2126       $new->pkgnum($dest_pkgnum);
2127       my $error = $new->replace($cust_svc);
2128       return $error if $error;
2129     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2130       if ( $DEBUG ) {
2131         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2132         warn "alternates to consider: ".
2133              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2134       }
2135       my @alternate = grep {
2136                              warn "considering alternate svcpart $_: ".
2137                                   "$target{$_} available in new package\n"
2138                                if $DEBUG;
2139                              $target{$_} > 0;
2140                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
2141       if ( @alternate ) {
2142         warn "alternate(s) found\n" if $DEBUG;
2143         my $change_svcpart = $alternate[0];
2144         $target{$change_svcpart}--;
2145         my $new = new FS::cust_svc { $cust_svc->hash };
2146         $new->svcpart($change_svcpart);
2147         $new->pkgnum($dest_pkgnum);
2148         my $error = $new->replace($cust_svc);
2149         return $error if $error;
2150       } else {
2151         $remaining++;
2152       }
2153     } else {
2154       $remaining++
2155     }
2156   }
2157   return $remaining;
2158 }
2159
2160 =item reexport
2161
2162 This method is deprecated.  See the I<depend_jobnum> option to the insert and
2163 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2164
2165 =cut
2166
2167 sub reexport {
2168   my $self = shift;
2169
2170   local $SIG{HUP} = 'IGNORE';
2171   local $SIG{INT} = 'IGNORE';
2172   local $SIG{QUIT} = 'IGNORE';
2173   local $SIG{TERM} = 'IGNORE';
2174   local $SIG{TSTP} = 'IGNORE';
2175   local $SIG{PIPE} = 'IGNORE';
2176
2177   my $oldAutoCommit = $FS::UID::AutoCommit;
2178   local $FS::UID::AutoCommit = 0;
2179   my $dbh = dbh;
2180
2181   foreach my $cust_svc ( $self->cust_svc ) {
2182     #false laziness w/svc_Common::insert
2183     my $svc_x = $cust_svc->svc_x;
2184     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2185       my $error = $part_export->export_insert($svc_x);
2186       if ( $error ) {
2187         $dbh->rollback if $oldAutoCommit;
2188         return $error;
2189       }
2190     }
2191   }
2192
2193   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2194   '';
2195
2196 }
2197
2198 =back
2199
2200 =head1 CLASS METHODS
2201
2202 =over 4
2203
2204 =item recurring_sql
2205
2206 Returns an SQL expression identifying recurring packages.
2207
2208 =cut
2209
2210 sub recurring_sql { "
2211   '0' != ( select freq from part_pkg
2212              where cust_pkg.pkgpart = part_pkg.pkgpart )
2213 "; }
2214
2215 =item onetime_sql
2216
2217 Returns an SQL expression identifying one-time packages.
2218
2219 =cut
2220
2221 sub onetime_sql { "
2222   '0' = ( select freq from part_pkg
2223             where cust_pkg.pkgpart = part_pkg.pkgpart )
2224 "; }
2225
2226 =item active_sql
2227
2228 Returns an SQL expression identifying active packages.
2229
2230 =cut
2231
2232 sub active_sql { "
2233   ". $_[0]->recurring_sql(). "
2234   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2235   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2236   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2237 "; }
2238
2239 =item not_yet_billed_sql
2240
2241 Returns an SQL expression identifying packages which have not yet been billed.
2242
2243 =cut
2244
2245 sub not_yet_billed_sql { "
2246       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
2247   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2248   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2249 "; }
2250
2251 =item inactive_sql
2252
2253 Returns an SQL expression identifying inactive packages (one-time packages
2254 that are otherwise unsuspended/uncancelled).
2255
2256 =cut
2257
2258 sub inactive_sql { "
2259   ". $_[0]->onetime_sql(). "
2260   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2261   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2262   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2263 "; }
2264
2265 =item susp_sql
2266 =item suspended_sql
2267
2268 Returns an SQL expression identifying suspended packages.
2269
2270 =cut
2271
2272 sub suspended_sql { susp_sql(@_); }
2273 sub susp_sql {
2274   #$_[0]->recurring_sql(). ' AND '.
2275   "
2276         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
2277     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
2278   ";
2279 }
2280
2281 =item cancel_sql
2282 =item cancelled_sql
2283
2284 Returns an SQL exprression identifying cancelled packages.
2285
2286 =cut
2287
2288 sub cancelled_sql { cancel_sql(@_); }
2289 sub cancel_sql { 
2290   #$_[0]->recurring_sql(). ' AND '.
2291   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2292 }
2293
2294 =item search_sql HASHREF
2295
2296 (Class method)
2297
2298 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2299 Valid parameters are
2300
2301 =over 4
2302
2303 =item agentnum
2304
2305 =item magic
2306
2307 active, inactive, suspended, cancel (or cancelled)
2308
2309 =item status
2310
2311 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2312
2313 =item custom
2314
2315  boolean selects custom packages
2316
2317 =item classnum
2318
2319 =item pkgpart
2320
2321 pkgpart or arrayref or hashref of pkgparts
2322
2323 =item setup
2324
2325 arrayref of beginning and ending epoch date
2326
2327 =item last_bill
2328
2329 arrayref of beginning and ending epoch date
2330
2331 =item bill
2332
2333 arrayref of beginning and ending epoch date
2334
2335 =item adjourn
2336
2337 arrayref of beginning and ending epoch date
2338
2339 =item susp
2340
2341 arrayref of beginning and ending epoch date
2342
2343 =item expire
2344
2345 arrayref of beginning and ending epoch date
2346
2347 =item cancel
2348
2349 arrayref of beginning and ending epoch date
2350
2351 =item query
2352
2353 pkgnum or APKG_pkgnum
2354
2355 =item cust_fields
2356
2357 a value suited to passing to FS::UI::Web::cust_header
2358
2359 =item CurrentUser
2360
2361 specifies the user for agent virtualization
2362
2363 =back
2364
2365 =cut
2366
2367 sub search_sql { 
2368   my ($class, $params) = @_;
2369   my @where = ();
2370
2371   ##
2372   # parse agent
2373   ##
2374
2375   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2376     push @where,
2377       "cust_main.agentnum = $1";
2378   }
2379
2380   ##
2381   # parse custnum
2382   ##
2383
2384   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2385     push @where,
2386       "cust_pkg.custnum = $1";
2387   }
2388
2389   ##
2390   # parse status
2391   ##
2392
2393   if (    $params->{'magic'}  eq 'active'
2394        || $params->{'status'} eq 'active' ) {
2395
2396     push @where, FS::cust_pkg->active_sql();
2397
2398   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
2399             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
2400
2401     push @where, FS::cust_pkg->not_yet_billed_sql();
2402
2403   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
2404             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2405
2406     push @where, FS::cust_pkg->inactive_sql();
2407
2408   } elsif (    $params->{'magic'}  eq 'suspended'
2409             || $params->{'status'} eq 'suspended'  ) {
2410
2411     push @where, FS::cust_pkg->suspended_sql();
2412
2413   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
2414             || $params->{'status'} =~ /^cancell?ed$/ ) {
2415
2416     push @where, FS::cust_pkg->cancelled_sql();
2417
2418   }
2419
2420   ###
2421   # parse package class
2422   ###
2423
2424   #false lazinessish w/graph/cust_bill_pkg.cgi
2425   my $classnum = 0;
2426   my @pkg_class = ();
2427   if ( exists($params->{'classnum'})
2428        && $params->{'classnum'} =~ /^(\d*)$/
2429      )
2430   {
2431     $classnum = $1;
2432     if ( $classnum ) { #a specific class
2433       push @where, "part_pkg.classnum = $classnum";
2434
2435       #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2436       #die "classnum $classnum not found!" unless $pkg_class[0];
2437       #$title .= $pkg_class[0]->classname.' ';
2438
2439     } elsif ( $classnum eq '' ) { #the empty class
2440
2441       push @where, "part_pkg.classnum IS NULL";
2442       #$title .= 'Empty class ';
2443       #@pkg_class = ( '(empty class)' );
2444     } elsif ( $classnum eq '0' ) {
2445       #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2446       #push @pkg_class, '(empty class)';
2447     } else {
2448       die "illegal classnum";
2449     }
2450   }
2451   #eslaf
2452
2453   ###
2454   # parse package report options
2455   ###
2456
2457   my @report_option = ();
2458   if ( exists($params->{'report_option'})
2459        && $params->{'report_option'} =~ /^([,\d]*)$/
2460      )
2461   {
2462     @report_option = split(',', $1);
2463   }
2464
2465   if (@report_option) {
2466     # this will result in the empty set for the dangling comma case as it should
2467     push @where, 
2468       map{ "0 < ( SELECT count(*) FROM part_pkg_option
2469                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
2470                     AND optionname = 'report_option_$_'
2471                     AND optionvalue = '1' )"
2472          } @report_option;
2473   }
2474
2475   #eslaf
2476
2477   ###
2478   # parse custom
2479   ###
2480
2481   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
2482
2483   ###
2484   # parse censustract
2485   ###
2486
2487   if ( exists($params->{'censustract'}) ) {
2488     $params->{'censustract'} =~ /^([.\d]*)$/;
2489     my $censustract = "cust_main.censustract = '$1'";
2490     $censustract .= ' OR cust_main.censustract is NULL' unless $1;
2491     push @where,  "( $censustract )";
2492   }
2493
2494   ###
2495   # parse part_pkg
2496   ###
2497
2498   if ( ref($params->{'pkgpart'}) ) {
2499
2500     my @pkgpart = ();
2501     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
2502       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
2503     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
2504       @pkgpart = @{ $params->{'pkgpart'} };
2505     } else {
2506       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
2507     }
2508
2509     @pkgpart = grep /^(\d+)$/, @pkgpart;
2510
2511     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
2512
2513   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2514     push @where, "pkgpart = $1";
2515   } 
2516
2517   ###
2518   # parse dates
2519   ###
2520
2521   my $orderby = '';
2522
2523   #false laziness w/report_cust_pkg.html
2524   my %disable = (
2525     'all'             => {},
2526     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2527     'active'          => { 'susp'=>1, 'cancel'=>1 },
2528     'suspended'       => { 'cancel' => 1 },
2529     'cancelled'       => {},
2530     ''                => {},
2531   );
2532
2533   foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2534
2535     next unless exists($params->{$field});
2536
2537     my($beginning, $ending) = @{$params->{$field}};
2538
2539     next if $beginning == 0 && $ending == 4294967295;
2540
2541     push @where,
2542       "cust_pkg.$field IS NOT NULL",
2543       "cust_pkg.$field >= $beginning",
2544       "cust_pkg.$field <= $ending";
2545
2546     $orderby ||= "ORDER BY cust_pkg.$field";
2547
2548   }
2549
2550   $orderby ||= 'ORDER BY bill';
2551
2552   ###
2553   # parse magic, legacy, etc.
2554   ###
2555
2556   if ( $params->{'magic'} &&
2557        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2558   ) {
2559
2560     $orderby = 'ORDER BY pkgnum';
2561
2562     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2563       push @where, "pkgpart = $1";
2564     }
2565
2566   } elsif ( $params->{'query'} eq 'pkgnum' ) {
2567
2568     $orderby = 'ORDER BY pkgnum';
2569
2570   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2571
2572     $orderby = 'ORDER BY pkgnum';
2573
2574     push @where, '0 < (
2575       SELECT count(*) FROM pkg_svc
2576        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
2577          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2578                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
2579                                      AND cust_svc.svcpart = pkg_svc.svcpart
2580                                 )
2581     )';
2582   
2583   }
2584
2585   ##
2586   # setup queries, links, subs, etc. for the search
2587   ##
2588
2589   # here is the agent virtualization
2590   if ($params->{CurrentUser}) {
2591     my $access_user =
2592       qsearchs('access_user', { username => $params->{CurrentUser} });
2593
2594     if ($access_user) {
2595       push @where, $access_user->agentnums_sql('table'=>'cust_main');
2596     }else{
2597       push @where, "1=0";
2598     }
2599   }else{
2600     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2601   }
2602
2603   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2604
2605   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
2606                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
2607                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
2608
2609   my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2610
2611   my $sql_query = {
2612     'table'       => 'cust_pkg',
2613     'hashref'     => {},
2614     'select'      => join(', ',
2615                                 'cust_pkg.*',
2616                                 ( map "part_pkg.$_", qw( pkg freq ) ),
2617                                 'pkg_class.classname',
2618                                 'cust_main.custnum as cust_main_custnum',
2619                                 FS::UI::Web::cust_sql_fields(
2620                                   $params->{'cust_fields'}
2621                                 ),
2622                      ),
2623     'extra_sql'   => "$extra_sql $orderby",
2624     'addl_from'   => $addl_from,
2625     'count_query' => $count_query,
2626   };
2627
2628 }
2629
2630 =item location_sql
2631
2632 Returns a list: the first item is an SQL fragment identifying matching 
2633 packages/customers via location (taking into account shipping and package
2634 address taxation, if enabled), and subsequent items are the parameters to
2635 substitute for the placeholders in that fragment.
2636
2637 =cut
2638
2639 sub location_sql {
2640   my($class, %opt) = @_;
2641   my $ornull = $opt{'ornull'};
2642
2643   my $conf = new FS::Conf;
2644
2645   # '?' placeholders in _location_sql_where
2646   my @bill_param;
2647   if ( $ornull ) {
2648     @bill_param = qw( county county state state state country );
2649   } else {
2650     @bill_param = qw( county state state country );
2651   }
2652   unshift @bill_param, 'county'; # unless $nec;
2653
2654   my $main_where;
2655   my @main_param;
2656   if ( $conf->exists('tax-ship_address') ) {
2657
2658     $main_where = "(
2659          (     ( ship_last IS NULL     OR  ship_last  = '' )
2660            AND ". _location_sql_where('cust_main', '', $ornull ). "
2661          )
2662       OR (       ship_last IS NOT NULL AND ship_last != ''
2663            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2664          )
2665     )";
2666     #    AND payby != 'COMP'
2667
2668     @main_param = ( @bill_param, @bill_param );
2669
2670   } else {
2671
2672     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2673     @main_param = @bill_param;
2674
2675   }
2676
2677   my $where;
2678   my @param;
2679   if ( $conf->exists('tax-pkg_address') ) {
2680
2681     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2682
2683     $where = " (
2684                     ( cust_pkg.locationnum IS     NULL AND $main_where )
2685                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
2686                )
2687              ";
2688     @param = ( @main_param, @bill_param );
2689   
2690   } else {
2691
2692     $where = $main_where;
2693     @param = @main_param;
2694
2695   }
2696
2697   ( $where, @param );
2698
2699 }
2700
2701 #subroutine, helper for location_sql
2702 sub _location_sql_where {
2703   my $table  = shift;
2704   my $prefix = @_ ? shift : '';
2705   my $ornull = @_ ? shift : '';
2706
2707 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2708
2709   $ornull = $ornull ? ' OR ? IS NULL ' : '';
2710
2711   my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
2712   my $or_empty_state =  " OR ( ? = '' AND $table.${prefix}state  IS NULL ) ";
2713
2714   "
2715         ( $table.${prefix}county  = ? $or_empty_county $ornull )
2716     AND ( $table.${prefix}state   = ? $or_empty_state  $ornull )
2717     AND   $table.${prefix}country = ?
2718   ";
2719 }
2720
2721 =head1 SUBROUTINES
2722
2723 =over 4
2724
2725 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2726
2727 CUSTNUM is a customer (see L<FS::cust_main>)
2728
2729 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2730 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
2731 permitted.
2732
2733 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2734 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
2735 new billing items.  An error is returned if this is not possible (see
2736 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
2737 parameter.
2738
2739 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2740 newly-created cust_pkg objects.
2741
2742 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2743 and inserted.  Multiple FS::pkg_referral records can be created by
2744 setting I<refnum> to an array reference of refnums or a hash reference with
2745 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
2746 record will be created corresponding to cust_main.refnum.
2747
2748 =cut
2749
2750 sub order {
2751   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2752
2753   my $conf = new FS::Conf;
2754
2755   # Transactionize this whole mess
2756   local $SIG{HUP} = 'IGNORE';
2757   local $SIG{INT} = 'IGNORE'; 
2758   local $SIG{QUIT} = 'IGNORE';
2759   local $SIG{TERM} = 'IGNORE';
2760   local $SIG{TSTP} = 'IGNORE'; 
2761   local $SIG{PIPE} = 'IGNORE'; 
2762
2763   my $oldAutoCommit = $FS::UID::AutoCommit;
2764   local $FS::UID::AutoCommit = 0;
2765   my $dbh = dbh;
2766
2767   my $error;
2768 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2769 #  return "Customer not found: $custnum" unless $cust_main;
2770
2771   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2772                          @$remove_pkgnum;
2773
2774   my $change = scalar(@old_cust_pkg) != 0;
2775
2776   my %hash = (); 
2777   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2778
2779     my $err_or_cust_pkg =
2780       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
2781                                 'refnum'  => $refnum,
2782                               );
2783
2784     unless (ref($err_or_cust_pkg)) {
2785       $dbh->rollback if $oldAutoCommit;
2786       return $err_or_cust_pkg;
2787     }
2788
2789     push @$return_cust_pkg, $err_or_cust_pkg;
2790     return '';
2791
2792   }
2793
2794   # Create the new packages.
2795   foreach my $pkgpart (@$pkgparts) {
2796     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2797                                       pkgpart => $pkgpart,
2798                                       refnum  => $refnum,
2799                                       %hash,
2800                                     };
2801     $error = $cust_pkg->insert( 'change' => $change );
2802     if ($error) {
2803       $dbh->rollback if $oldAutoCommit;
2804       return $error;
2805     }
2806     push @$return_cust_pkg, $cust_pkg;
2807   }
2808   # $return_cust_pkg now contains refs to all of the newly 
2809   # created packages.
2810
2811   # Transfer services and cancel old packages.
2812   foreach my $old_pkg (@old_cust_pkg) {
2813
2814     foreach my $new_pkg (@$return_cust_pkg) {
2815       $error = $old_pkg->transfer($new_pkg);
2816       if ($error and $error == 0) {
2817         # $old_pkg->transfer failed.
2818         $dbh->rollback if $oldAutoCommit;
2819         return $error;
2820       }
2821     }
2822
2823     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2824       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2825       foreach my $new_pkg (@$return_cust_pkg) {
2826         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2827         if ($error and $error == 0) {
2828           # $old_pkg->transfer failed.
2829         $dbh->rollback if $oldAutoCommit;
2830         return $error;
2831         }
2832       }
2833     }
2834
2835     if ($error > 0) {
2836       # Transfers were successful, but we went through all of the 
2837       # new packages and still had services left on the old package.
2838       # We can't cancel the package under the circumstances, so abort.
2839       $dbh->rollback if $oldAutoCommit;
2840       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2841     }
2842     $error = $old_pkg->cancel( quiet=>1 );
2843     if ($error) {
2844       $dbh->rollback;
2845       return $error;
2846     }
2847   }
2848   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2849   '';
2850 }
2851
2852 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2853
2854 A bulk change method to change packages for multiple customers.
2855
2856 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2857 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
2858 permitted.
2859
2860 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2861 replace.  The services (see L<FS::cust_svc>) are moved to the
2862 new billing items.  An error is returned if this is not possible (see
2863 L<FS::pkg_svc>).
2864
2865 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2866 newly-created cust_pkg objects.
2867
2868 =cut
2869
2870 sub bulk_change {
2871   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2872
2873   # Transactionize this whole mess
2874   local $SIG{HUP} = 'IGNORE';
2875   local $SIG{INT} = 'IGNORE'; 
2876   local $SIG{QUIT} = 'IGNORE';
2877   local $SIG{TERM} = 'IGNORE';
2878   local $SIG{TSTP} = 'IGNORE'; 
2879   local $SIG{PIPE} = 'IGNORE'; 
2880
2881   my $oldAutoCommit = $FS::UID::AutoCommit;
2882   local $FS::UID::AutoCommit = 0;
2883   my $dbh = dbh;
2884
2885   my @errors;
2886   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2887                          @$remove_pkgnum;
2888
2889   while(scalar(@old_cust_pkg)) {
2890     my @return = ();
2891     my $custnum = $old_cust_pkg[0]->custnum;
2892     my (@remove) = map { $_->pkgnum }
2893                    grep { $_->custnum == $custnum } @old_cust_pkg;
2894     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2895
2896     my $error = order $custnum, $pkgparts, \@remove, \@return;
2897
2898     push @errors, $error
2899       if $error;
2900     push @$return_cust_pkg, @return;
2901   }
2902
2903   if (scalar(@errors)) {
2904     $dbh->rollback if $oldAutoCommit;
2905     return join(' / ', @errors);
2906   }
2907
2908   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2909   '';
2910 }
2911
2912 =item insert_reason
2913
2914 Associates this package with a (suspension or cancellation) reason (see
2915 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2916 L<FS::reason>).
2917
2918 Available options are:
2919
2920 =over 4
2921
2922 =item reason
2923
2924 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.
2925
2926 =item reason_otaker
2927
2928 the access_user (see L<FS::access_user>) providing the reason
2929
2930 =item date
2931
2932 a unix timestamp 
2933
2934 =item action
2935
2936 the action (cancel, susp, adjourn, expire) associated with the reason
2937
2938 =back
2939
2940 If there is an error, returns the error, otherwise returns false.
2941
2942 =cut
2943
2944 sub insert_reason {
2945   my ($self, %options) = @_;
2946
2947   my $otaker = $options{reason_otaker} ||
2948                $FS::CurrentUser::CurrentUser->username;
2949
2950   my $reasonnum;
2951   if ( $options{'reason'} =~ /^(\d+)$/ ) {
2952
2953     $reasonnum = $1;
2954
2955   } elsif ( ref($options{'reason'}) ) {
2956   
2957     return 'Enter a new reason (or select an existing one)'
2958       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2959
2960     my $reason = new FS::reason({
2961       'reason_type' => $options{'reason'}->{'typenum'},
2962       'reason'      => $options{'reason'}->{'reason'},
2963     });
2964     my $error = $reason->insert;
2965     return $error if $error;
2966
2967     $reasonnum = $reason->reasonnum;
2968
2969   } else {
2970     return "Unparsable reason: ". $options{'reason'};
2971   }
2972
2973   my $cust_pkg_reason =
2974     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2975                               'reasonnum' => $reasonnum, 
2976                               'otaker'    => $otaker,
2977                               'action'    => substr(uc($options{'action'}),0,1),
2978                               'date'      => $options{'date'}
2979                                                ? $options{'date'}
2980                                                : time,
2981                             });
2982
2983   $cust_pkg_reason->insert;
2984 }
2985
2986 =item set_usage USAGE_VALUE_HASHREF 
2987
2988 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2989 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2990 upbytes, downbytes, and totalbytes are appropriate keys.
2991
2992 All svc_accts which are part of this package have their values reset.
2993
2994 =cut
2995
2996 sub set_usage {
2997   my ($self, $valueref, %opt) = @_;
2998
2999   foreach my $cust_svc ($self->cust_svc){
3000     my $svc_x = $cust_svc->svc_x;
3001     $svc_x->set_usage($valueref, %opt)
3002       if $svc_x->can("set_usage");
3003   }
3004 }
3005
3006 =item recharge USAGE_VALUE_HASHREF 
3007
3008 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3009 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3010 upbytes, downbytes, and totalbytes are appropriate keys.
3011
3012 All svc_accts which are part of this package have their values incremented.
3013
3014 =cut
3015
3016 sub recharge {
3017   my ($self, $valueref) = @_;
3018
3019   foreach my $cust_svc ($self->cust_svc){
3020     my $svc_x = $cust_svc->svc_x;
3021     $svc_x->recharge($valueref)
3022       if $svc_x->can("recharge");
3023   }
3024 }
3025
3026 =back
3027
3028 =head1 BUGS
3029
3030 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
3031
3032 In sub order, the @pkgparts array (passed by reference) is clobbered.
3033
3034 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
3035 method to pass dates to the recur_prog expression, it should do so.
3036
3037 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
3038 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
3039 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
3040 configuration values.  Probably need a subroutine which decides what to do
3041 based on whether or not we've fetched the user yet, rather than a hash.  See
3042 FS::UID and the TODO.
3043
3044 Now that things are transactional should the check in the insert method be
3045 moved to check ?
3046
3047 =head1 SEE ALSO
3048
3049 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
3050 L<FS::pkg_svc>, schema.html from the base documentation
3051
3052 =cut
3053
3054 1;
3055