8bbf3765dc6aedea949cb8a7671d42c69575f64c
[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.cancel IS NULL OR cust_pkg.cancel = 0 )
2235   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2236 "; }
2237
2238 =item not_yet_billed_sql
2239
2240 Returns an SQL expression identifying packages which have not yet been billed.
2241
2242 =cut
2243
2244 sub not_yet_billed_sql { "
2245       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
2246   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2247   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2248 "; }
2249
2250 =item inactive_sql
2251
2252 Returns an SQL expression identifying inactive packages (one-time packages
2253 that are otherwise unsuspended/uncancelled).
2254
2255 =cut
2256
2257 sub inactive_sql { "
2258   ". $_[0]->onetime_sql(). "
2259   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
2260   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
2261   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
2262 "; }
2263
2264 =item susp_sql
2265 =item suspended_sql
2266
2267 Returns an SQL expression identifying suspended packages.
2268
2269 =cut
2270
2271 sub suspended_sql { susp_sql(@_); }
2272 sub susp_sql {
2273   #$_[0]->recurring_sql(). ' AND '.
2274   "
2275         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
2276     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
2277   ";
2278 }
2279
2280 =item cancel_sql
2281 =item cancelled_sql
2282
2283 Returns an SQL exprression identifying cancelled packages.
2284
2285 =cut
2286
2287 sub cancelled_sql { cancel_sql(@_); }
2288 sub cancel_sql { 
2289   #$_[0]->recurring_sql(). ' AND '.
2290   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
2291 }
2292
2293 =item search_sql HASHREF
2294
2295 (Class method)
2296
2297 Returns a qsearch hash expression to search for parameters specified in HASHREF.
2298 Valid parameters are
2299
2300 =over 4
2301
2302 =item agentnum
2303
2304 =item magic
2305
2306 active, inactive, suspended, cancel (or cancelled)
2307
2308 =item status
2309
2310 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
2311
2312 =item custom
2313
2314  boolean selects custom packages
2315
2316 =item classnum
2317
2318 =item pkgpart
2319
2320 pkgpart or arrayref or hashref of pkgparts
2321
2322 =item setup
2323
2324 arrayref of beginning and ending epoch date
2325
2326 =item last_bill
2327
2328 arrayref of beginning and ending epoch date
2329
2330 =item bill
2331
2332 arrayref of beginning and ending epoch date
2333
2334 =item adjourn
2335
2336 arrayref of beginning and ending epoch date
2337
2338 =item susp
2339
2340 arrayref of beginning and ending epoch date
2341
2342 =item expire
2343
2344 arrayref of beginning and ending epoch date
2345
2346 =item cancel
2347
2348 arrayref of beginning and ending epoch date
2349
2350 =item query
2351
2352 pkgnum or APKG_pkgnum
2353
2354 =item cust_fields
2355
2356 a value suited to passing to FS::UI::Web::cust_header
2357
2358 =item CurrentUser
2359
2360 specifies the user for agent virtualization
2361
2362 =back
2363
2364 =cut
2365
2366 sub search_sql { 
2367   my ($class, $params) = @_;
2368   my @where = ();
2369
2370   ##
2371   # parse agent
2372   ##
2373
2374   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
2375     push @where,
2376       "cust_main.agentnum = $1";
2377   }
2378
2379   ##
2380   # parse custnum
2381   ##
2382
2383   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
2384     push @where,
2385       "cust_pkg.custnum = $1";
2386   }
2387
2388   ##
2389   # parse status
2390   ##
2391
2392   if (    $params->{'magic'}  eq 'active'
2393        || $params->{'status'} eq 'active' ) {
2394
2395     push @where, FS::cust_pkg->active_sql();
2396
2397   } elsif (    $params->{'magic'}  eq 'not yet billed'
2398             || $params->{'status'} eq 'not yet billed' ) {
2399
2400     push @where, FS::cust_pkg->not_yet_billed_sql();
2401
2402   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
2403             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
2404
2405     push @where, FS::cust_pkg->inactive_sql();
2406
2407   } elsif (    $params->{'magic'}  eq 'suspended'
2408             || $params->{'status'} eq 'suspended'  ) {
2409
2410     push @where, FS::cust_pkg->suspended_sql();
2411
2412   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
2413             || $params->{'status'} =~ /^cancell?ed$/ ) {
2414
2415     push @where, FS::cust_pkg->cancelled_sql();
2416
2417   }
2418
2419   ###
2420   # parse package class
2421   ###
2422
2423   #false lazinessish w/graph/cust_bill_pkg.cgi
2424   my $classnum = 0;
2425   my @pkg_class = ();
2426   if ( exists($params->{'classnum'})
2427        && $params->{'classnum'} =~ /^(\d*)$/
2428      )
2429   {
2430     $classnum = $1;
2431     if ( $classnum ) { #a specific class
2432       push @where, "classnum = $classnum";
2433
2434       #@pkg_class = ( qsearchs('pkg_class', { 'classnum' => $classnum } ) );
2435       #die "classnum $classnum not found!" unless $pkg_class[0];
2436       #$title .= $pkg_class[0]->classname.' ';
2437
2438     } elsif ( $classnum eq '' ) { #the empty class
2439
2440       push @where, "classnum IS NULL";
2441       #$title .= 'Empty class ';
2442       #@pkg_class = ( '(empty class)' );
2443     } elsif ( $classnum eq '0' ) {
2444       #@pkg_class = qsearch('pkg_class', {} ); # { 'disabled' => '' } );
2445       #push @pkg_class, '(empty class)';
2446     } else {
2447       die "illegal classnum";
2448     }
2449   }
2450   #eslaf
2451
2452   ###
2453   # parse package report options
2454   ###
2455
2456   my @report_option = ();
2457   if ( exists($params->{'report_option'})
2458        && $params->{'report_option'} =~ /^([,\d]*)$/
2459      )
2460   {
2461     @report_option = split(',', $1);
2462   }
2463
2464   if (@report_option) {
2465     # this will result in the empty set for the dangling comma case as it should
2466     push @where, 
2467       map{ "0 < ( SELECT count(*) FROM part_pkg_option
2468                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
2469                     AND optionname = 'report_option_$_'
2470                     AND optionvalue = '1' )"
2471          } @report_option;
2472   }
2473
2474   #eslaf
2475
2476   ###
2477   # parse custom
2478   ###
2479
2480   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
2481
2482   ###
2483   # parse censustract
2484   ###
2485
2486   if ( exists($params->{'censustract'}) ) {
2487     $params->{'censustract'} =~ /^([.\d]*)$/;
2488     my $censustract = "cust_main.censustract = '$1'";
2489     $censustract .= ' OR cust_main.censustract is NULL' unless $1;
2490     push @where,  "( $censustract )";
2491   }
2492
2493   ###
2494   # parse part_pkg
2495   ###
2496
2497   if ( ref($params->{'pkgpart'}) ) {
2498
2499     my @pkgpart = ();
2500     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
2501       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
2502     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
2503       @pkgpart = @{ $params->{'pkgpart'} };
2504     } else {
2505       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
2506     }
2507
2508     @pkgpart = grep /^(\d+)$/, @pkgpart;
2509
2510     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
2511
2512   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2513     push @where, "pkgpart = $1";
2514   } 
2515
2516   ###
2517   # parse dates
2518   ###
2519
2520   my $orderby = '';
2521
2522   #false laziness w/report_cust_pkg.html
2523   my %disable = (
2524     'all'             => {},
2525     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
2526     'active'          => { 'susp'=>1, 'cancel'=>1 },
2527     'suspended'       => { 'cancel' => 1 },
2528     'cancelled'       => {},
2529     ''                => {},
2530   );
2531
2532   foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
2533
2534     next unless exists($params->{$field});
2535
2536     my($beginning, $ending) = @{$params->{$field}};
2537
2538     next if $beginning == 0 && $ending == 4294967295;
2539
2540     push @where,
2541       "cust_pkg.$field IS NOT NULL",
2542       "cust_pkg.$field >= $beginning",
2543       "cust_pkg.$field <= $ending";
2544
2545     $orderby ||= "ORDER BY cust_pkg.$field";
2546
2547   }
2548
2549   $orderby ||= 'ORDER BY bill';
2550
2551   ###
2552   # parse magic, legacy, etc.
2553   ###
2554
2555   if ( $params->{'magic'} &&
2556        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
2557   ) {
2558
2559     $orderby = 'ORDER BY pkgnum';
2560
2561     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
2562       push @where, "pkgpart = $1";
2563     }
2564
2565   } elsif ( $params->{'query'} eq 'pkgnum' ) {
2566
2567     $orderby = 'ORDER BY pkgnum';
2568
2569   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
2570
2571     $orderby = 'ORDER BY pkgnum';
2572
2573     push @where, '0 < (
2574       SELECT count(*) FROM pkg_svc
2575        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
2576          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
2577                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
2578                                      AND cust_svc.svcpart = pkg_svc.svcpart
2579                                 )
2580     )';
2581   
2582   }
2583
2584   ##
2585   # setup queries, links, subs, etc. for the search
2586   ##
2587
2588   # here is the agent virtualization
2589   if ($params->{CurrentUser}) {
2590     my $access_user =
2591       qsearchs('access_user', { username => $params->{CurrentUser} });
2592
2593     if ($access_user) {
2594       push @where, $access_user->agentnums_sql('table'=>'cust_main');
2595     }else{
2596       push @where, "1=0";
2597     }
2598   }else{
2599     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
2600   }
2601
2602   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
2603
2604   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
2605                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
2606                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
2607
2608   my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql";
2609
2610   my $sql_query = {
2611     'table'       => 'cust_pkg',
2612     'hashref'     => {},
2613     'select'      => join(', ',
2614                                 'cust_pkg.*',
2615                                 ( map "part_pkg.$_", qw( pkg freq ) ),
2616                                 'pkg_class.classname',
2617                                 'cust_main.custnum as cust_main_custnum',
2618                                 FS::UI::Web::cust_sql_fields(
2619                                   $params->{'cust_fields'}
2620                                 ),
2621                      ),
2622     'extra_sql'   => "$extra_sql $orderby",
2623     'addl_from'   => $addl_from,
2624     'count_query' => $count_query,
2625   };
2626
2627 }
2628
2629 =item location_sql
2630
2631 Returns a list: the first item is an SQL fragment identifying matching 
2632 packages/customers via location (taking into account shipping and package
2633 address taxation, if enabled), and subsequent items are the parameters to
2634 substitute for the placeholders in that fragment.
2635
2636 =cut
2637
2638 sub location_sql {
2639   my($class, %opt) = @_;
2640   my $ornull = $opt{'ornull'};
2641
2642   my $conf = new FS::Conf;
2643
2644   # '?' placeholders in _location_sql_where
2645   my @bill_param;
2646   if ( $ornull ) {
2647     @bill_param = qw( county county state state state country );
2648   } else {
2649     @bill_param = qw( county state state country );
2650   }
2651   unshift @bill_param, 'county'; # unless $nec;
2652
2653   my $main_where;
2654   my @main_param;
2655   if ( $conf->exists('tax-ship_address') ) {
2656
2657     $main_where = "(
2658          (     ( ship_last IS NULL     OR  ship_last  = '' )
2659            AND ". _location_sql_where('cust_main', '', $ornull ). "
2660          )
2661       OR (       ship_last IS NOT NULL AND ship_last != ''
2662            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
2663          )
2664     )";
2665     #    AND payby != 'COMP'
2666
2667     @main_param = ( @bill_param, @bill_param );
2668
2669   } else {
2670
2671     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
2672     @main_param = @bill_param;
2673
2674   }
2675
2676   my $where;
2677   my @param;
2678   if ( $conf->exists('tax-pkg_address') ) {
2679
2680     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
2681
2682     $where = " (
2683                     ( cust_pkg.locationnum IS     NULL AND $main_where )
2684                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
2685                )
2686              ";
2687     @param = ( @main_param, @bill_param );
2688   
2689   } else {
2690
2691     $where = $main_where;
2692     @param = @main_param;
2693
2694   }
2695
2696   ( $where, @param );
2697
2698 }
2699
2700 #subroutine, helper for location_sql
2701 sub _location_sql_where {
2702   my $table  = shift;
2703   my $prefix = @_ ? shift : '';
2704   my $ornull = @_ ? shift : '';
2705
2706 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
2707
2708   $ornull = $ornull ? ' OR ? IS NULL ' : '';
2709
2710   my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
2711   my $or_empty_state =  " OR ( ? = '' AND $table.${prefix}state  IS NULL ) ";
2712
2713   "
2714         ( $table.${prefix}county  = ? $or_empty_county $ornull )
2715     AND ( $table.${prefix}state   = ? $or_empty_state  $ornull )
2716     AND   $table.${prefix}country = ?
2717   ";
2718 }
2719
2720 =head1 SUBROUTINES
2721
2722 =over 4
2723
2724 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
2725
2726 CUSTNUM is a customer (see L<FS::cust_main>)
2727
2728 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2729 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
2730 permitted.
2731
2732 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
2733 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
2734 new billing items.  An error is returned if this is not possible (see
2735 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
2736 parameter.
2737
2738 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2739 newly-created cust_pkg objects.
2740
2741 REFNUM, if specified, will specify the FS::pkg_referral record to be created
2742 and inserted.  Multiple FS::pkg_referral records can be created by
2743 setting I<refnum> to an array reference of refnums or a hash reference with
2744 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
2745 record will be created corresponding to cust_main.refnum.
2746
2747 =cut
2748
2749 sub order {
2750   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
2751
2752   my $conf = new FS::Conf;
2753
2754   # Transactionize this whole mess
2755   local $SIG{HUP} = 'IGNORE';
2756   local $SIG{INT} = 'IGNORE'; 
2757   local $SIG{QUIT} = 'IGNORE';
2758   local $SIG{TERM} = 'IGNORE';
2759   local $SIG{TSTP} = 'IGNORE'; 
2760   local $SIG{PIPE} = 'IGNORE'; 
2761
2762   my $oldAutoCommit = $FS::UID::AutoCommit;
2763   local $FS::UID::AutoCommit = 0;
2764   my $dbh = dbh;
2765
2766   my $error;
2767 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
2768 #  return "Customer not found: $custnum" unless $cust_main;
2769
2770   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2771                          @$remove_pkgnum;
2772
2773   my $change = scalar(@old_cust_pkg) != 0;
2774
2775   my %hash = (); 
2776   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
2777
2778     my $err_or_cust_pkg =
2779       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
2780                                 'refnum'  => $refnum,
2781                               );
2782
2783     unless (ref($err_or_cust_pkg)) {
2784       $dbh->rollback if $oldAutoCommit;
2785       return $err_or_cust_pkg;
2786     }
2787
2788     push @$return_cust_pkg, $err_or_cust_pkg;
2789     return '';
2790
2791   }
2792
2793   # Create the new packages.
2794   foreach my $pkgpart (@$pkgparts) {
2795     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
2796                                       pkgpart => $pkgpart,
2797                                       refnum  => $refnum,
2798                                       %hash,
2799                                     };
2800     $error = $cust_pkg->insert( 'change' => $change );
2801     if ($error) {
2802       $dbh->rollback if $oldAutoCommit;
2803       return $error;
2804     }
2805     push @$return_cust_pkg, $cust_pkg;
2806   }
2807   # $return_cust_pkg now contains refs to all of the newly 
2808   # created packages.
2809
2810   # Transfer services and cancel old packages.
2811   foreach my $old_pkg (@old_cust_pkg) {
2812
2813     foreach my $new_pkg (@$return_cust_pkg) {
2814       $error = $old_pkg->transfer($new_pkg);
2815       if ($error and $error == 0) {
2816         # $old_pkg->transfer failed.
2817         $dbh->rollback if $oldAutoCommit;
2818         return $error;
2819       }
2820     }
2821
2822     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2823       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2824       foreach my $new_pkg (@$return_cust_pkg) {
2825         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
2826         if ($error and $error == 0) {
2827           # $old_pkg->transfer failed.
2828         $dbh->rollback if $oldAutoCommit;
2829         return $error;
2830         }
2831       }
2832     }
2833
2834     if ($error > 0) {
2835       # Transfers were successful, but we went through all of the 
2836       # new packages and still had services left on the old package.
2837       # We can't cancel the package under the circumstances, so abort.
2838       $dbh->rollback if $oldAutoCommit;
2839       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
2840     }
2841     $error = $old_pkg->cancel( quiet=>1 );
2842     if ($error) {
2843       $dbh->rollback;
2844       return $error;
2845     }
2846   }
2847   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2848   '';
2849 }
2850
2851 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
2852
2853 A bulk change method to change packages for multiple customers.
2854
2855 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
2856 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
2857 permitted.
2858
2859 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
2860 replace.  The services (see L<FS::cust_svc>) are moved to the
2861 new billing items.  An error is returned if this is not possible (see
2862 L<FS::pkg_svc>).
2863
2864 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
2865 newly-created cust_pkg objects.
2866
2867 =cut
2868
2869 sub bulk_change {
2870   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
2871
2872   # Transactionize this whole mess
2873   local $SIG{HUP} = 'IGNORE';
2874   local $SIG{INT} = 'IGNORE'; 
2875   local $SIG{QUIT} = 'IGNORE';
2876   local $SIG{TERM} = 'IGNORE';
2877   local $SIG{TSTP} = 'IGNORE'; 
2878   local $SIG{PIPE} = 'IGNORE'; 
2879
2880   my $oldAutoCommit = $FS::UID::AutoCommit;
2881   local $FS::UID::AutoCommit = 0;
2882   my $dbh = dbh;
2883
2884   my @errors;
2885   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
2886                          @$remove_pkgnum;
2887
2888   while(scalar(@old_cust_pkg)) {
2889     my @return = ();
2890     my $custnum = $old_cust_pkg[0]->custnum;
2891     my (@remove) = map { $_->pkgnum }
2892                    grep { $_->custnum == $custnum } @old_cust_pkg;
2893     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
2894
2895     my $error = order $custnum, $pkgparts, \@remove, \@return;
2896
2897     push @errors, $error
2898       if $error;
2899     push @$return_cust_pkg, @return;
2900   }
2901
2902   if (scalar(@errors)) {
2903     $dbh->rollback if $oldAutoCommit;
2904     return join(' / ', @errors);
2905   }
2906
2907   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2908   '';
2909 }
2910
2911 =item insert_reason
2912
2913 Associates this package with a (suspension or cancellation) reason (see
2914 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2915 L<FS::reason>).
2916
2917 Available options are:
2918
2919 =over 4
2920
2921 =item reason
2922
2923 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.
2924
2925 =item reason_otaker
2926
2927 the access_user (see L<FS::access_user>) providing the reason
2928
2929 =item date
2930
2931 a unix timestamp 
2932
2933 =item action
2934
2935 the action (cancel, susp, adjourn, expire) associated with the reason
2936
2937 =back
2938
2939 If there is an error, returns the error, otherwise returns false.
2940
2941 =cut
2942
2943 sub insert_reason {
2944   my ($self, %options) = @_;
2945
2946   my $otaker = $options{reason_otaker} ||
2947                $FS::CurrentUser::CurrentUser->username;
2948
2949   my $reasonnum;
2950   if ( $options{'reason'} =~ /^(\d+)$/ ) {
2951
2952     $reasonnum = $1;
2953
2954   } elsif ( ref($options{'reason'}) ) {
2955   
2956     return 'Enter a new reason (or select an existing one)'
2957       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2958
2959     my $reason = new FS::reason({
2960       'reason_type' => $options{'reason'}->{'typenum'},
2961       'reason'      => $options{'reason'}->{'reason'},
2962     });
2963     my $error = $reason->insert;
2964     return $error if $error;
2965
2966     $reasonnum = $reason->reasonnum;
2967
2968   } else {
2969     return "Unparsable reason: ". $options{'reason'};
2970   }
2971
2972   my $cust_pkg_reason =
2973     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2974                               'reasonnum' => $reasonnum, 
2975                               'otaker'    => $otaker,
2976                               'action'    => substr(uc($options{'action'}),0,1),
2977                               'date'      => $options{'date'}
2978                                                ? $options{'date'}
2979                                                : time,
2980                             });
2981
2982   $cust_pkg_reason->insert;
2983 }
2984
2985 =item set_usage USAGE_VALUE_HASHREF 
2986
2987 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
2988 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
2989 upbytes, downbytes, and totalbytes are appropriate keys.
2990
2991 All svc_accts which are part of this package have their values reset.
2992
2993 =cut
2994
2995 sub set_usage {
2996   my ($self, $valueref, %opt) = @_;
2997
2998   foreach my $cust_svc ($self->cust_svc){
2999     my $svc_x = $cust_svc->svc_x;
3000     $svc_x->set_usage($valueref, %opt)
3001       if $svc_x->can("set_usage");
3002   }
3003 }
3004
3005 =item recharge USAGE_VALUE_HASHREF 
3006
3007 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3008 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3009 upbytes, downbytes, and totalbytes are appropriate keys.
3010
3011 All svc_accts which are part of this package have their values incremented.
3012
3013 =cut
3014
3015 sub recharge {
3016   my ($self, $valueref) = @_;
3017
3018   foreach my $cust_svc ($self->cust_svc){
3019     my $svc_x = $cust_svc->svc_x;
3020     $svc_x->recharge($valueref)
3021       if $svc_x->can("recharge");
3022   }
3023 }
3024
3025 =back
3026
3027 =head1 BUGS
3028
3029 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
3030
3031 In sub order, the @pkgparts array (passed by reference) is clobbered.
3032
3033 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
3034 method to pass dates to the recur_prog expression, it should do so.
3035
3036 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
3037 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
3038 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
3039 configuration values.  Probably need a subroutine which decides what to do
3040 based on whether or not we've fetched the user yet, rather than a hash.  See
3041 FS::UID and the TODO.
3042
3043 Now that things are transactional should the check in the insert method be
3044 moved to check ?
3045
3046 =head1 SEE ALSO
3047
3048 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
3049 L<FS::pkg_svc>, schema.html from the base documentation
3050
3051 =cut
3052
3053 1;
3054