merge
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use base qw( FS::otaker_Mixin FS::cust_main_Mixin
5              FS::contact_Mixin FS::location_Mixin
6              FS::m2m_Common FS::option_Common );
7 use vars qw($disable_agentcheck $DEBUG $me);
8 use Carp qw(cluck);
9 use Scalar::Util qw( blessed );
10 use List::Util qw(min max);
11 use Tie::IxHash;
12 use Time::Local qw( timelocal timelocal_nocheck );
13 use MIME::Entity;
14 use FS::UID qw( dbh driver_name );
15 use FS::Misc qw( send_email );
16 use FS::Record qw( qsearch qsearchs fields );
17 use FS::CurrentUser;
18 use FS::cust_svc;
19 use FS::part_pkg;
20 use FS::cust_main;
21 use FS::contact;
22 use FS::cust_location;
23 use FS::pkg_svc;
24 use FS::cust_bill_pkg;
25 use FS::cust_pkg_detail;
26 use FS::cust_pkg_usage;
27 use FS::cdr_cust_pkg_usage;
28 use FS::cust_event;
29 use FS::h_cust_svc;
30 use FS::reg_code;
31 use FS::part_svc;
32 use FS::cust_pkg_reason;
33 use FS::reason;
34 use FS::cust_pkg_discount;
35 use FS::discount;
36 use FS::UI::Web;
37 use FS::sales;
38
39 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
40 # setup }
41 # because they load configuration by setting FS::UID::callback (see TODO)
42 use FS::svc_acct;
43 use FS::svc_domain;
44 use FS::svc_www;
45 use FS::svc_forward;
46
47 # for sending cancel emails in sub cancel
48 use FS::Conf;
49
50 $DEBUG = 0;
51 $me = '[FS::cust_pkg]';
52
53 $disable_agentcheck = 0;
54
55 sub _cache {
56   my $self = shift;
57   my ( $hashref, $cache ) = @_;
58   #if ( $hashref->{'pkgpart'} ) {
59   if ( $hashref->{'pkg'} ) {
60     # #@{ $self->{'_pkgnum'} } = ();
61     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
62     # $self->{'_pkgpart'} = $subcache;
63     # #push @{ $self->{'_pkgnum'} },
64     #   FS::part_pkg->new_or_cached($hashref, $subcache);
65     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
66   }
67   if ( exists $hashref->{'svcnum'} ) {
68     #@{ $self->{'_pkgnum'} } = ();
69     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
70     $self->{'_svcnum'} = $subcache;
71     #push @{ $self->{'_pkgnum'} },
72     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
73   }
74 }
75
76 =head1 NAME
77
78 FS::cust_pkg - Object methods for cust_pkg objects
79
80 =head1 SYNOPSIS
81
82   use FS::cust_pkg;
83
84   $record = new FS::cust_pkg \%hash;
85   $record = new FS::cust_pkg { 'column' => 'value' };
86
87   $error = $record->insert;
88
89   $error = $new_record->replace($old_record);
90
91   $error = $record->delete;
92
93   $error = $record->check;
94
95   $error = $record->cancel;
96
97   $error = $record->suspend;
98
99   $error = $record->unsuspend;
100
101   $part_pkg = $record->part_pkg;
102
103   @labels = $record->labels;
104
105   $seconds = $record->seconds_since($timestamp);
106
107   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
108   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
109
110 =head1 DESCRIPTION
111
112 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
113 inherits from FS::Record.  The following fields are currently supported:
114
115 =over 4
116
117 =item pkgnum
118
119 Primary key (assigned automatically for new billing items)
120
121 =item custnum
122
123 Customer (see L<FS::cust_main>)
124
125 =item pkgpart
126
127 Billing item definition (see L<FS::part_pkg>)
128
129 =item locationnum
130
131 Optional link to package location (see L<FS::location>)
132
133 =item order_date
134
135 date package was ordered (also remains same on changes)
136
137 =item start_date
138
139 date
140
141 =item setup
142
143 date
144
145 =item bill
146
147 date (next bill date)
148
149 =item last_bill
150
151 last bill date
152
153 =item adjourn
154
155 date
156
157 =item susp
158
159 date
160
161 =item expire
162
163 date
164
165 =item contract_end
166
167 date
168
169 =item cancel
170
171 date
172
173 =item usernum
174
175 order taker (see L<FS::access_user>)
176
177 =item manual_flag
178
179 If this field is set to 1, disables the automatic
180 unsuspension of this package when using the B<unsuspendauto> config option.
181
182 =item quantity
183
184 If not set, defaults to 1
185
186 =item change_date
187
188 Date of change from previous package
189
190 =item change_pkgnum
191
192 Previous pkgnum
193
194 =item change_pkgpart
195
196 Previous pkgpart
197
198 =item change_locationnum
199
200 Previous locationnum
201
202 =item waive_setup
203
204 =item main_pkgnum
205
206 The pkgnum of the package that this package is supplemental to, if any.
207
208 =item pkglinknum
209
210 The package link (L<FS::part_pkg_link>) that defines this supplemental
211 package, if it is one.
212
213 =item change_to_pkgnum
214
215 The pkgnum of the package this one will be "changed to" in the future
216 (on its expiration date).
217
218 =back
219
220 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
221 are specified as UNIX timestamps; see L<perlfunc/"time">.  Also see
222 L<Time::Local> and L<Date::Parse> for conversion functions.
223
224 =head1 METHODS
225
226 =over 4
227
228 =item new HASHREF
229
230 Create a new billing item.  To add the item to the database, see L<"insert">.
231
232 =cut
233
234 sub table { 'cust_pkg'; }
235 sub cust_linked { $_[0]->cust_main_custnum || $_[0]->custnum } 
236 sub cust_unlinked_msg {
237   my $self = shift;
238   "WARNING: can't find cust_main.custnum ". $self->custnum.
239   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
240 }
241
242 =item insert [ OPTION => VALUE ... ]
243
244 Adds this billing item to the database ("Orders" the item).  If there is an
245 error, returns the error, otherwise returns false.
246
247 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
248 will be used to look up the package definition and agent restrictions will be
249 ignored.
250
251 If the additional field I<refnum> is defined, an FS::pkg_referral record will
252 be created and inserted.  Multiple FS::pkg_referral records can be created by
253 setting I<refnum> to an array reference of refnums or a hash reference with
254 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
255 record will be created corresponding to cust_main.refnum.
256
257 The following options are available:
258
259 =over 4
260
261 =item change
262
263 If set true, supresses actions that should only be taken for new package
264 orders.  (Currently this includes: intro periods when delay_setup is on.)
265
266 =item options
267
268 cust_pkg_option records will be created
269
270 =item ticket_subject
271
272 a ticket will be added to this customer with this subject
273
274 =item ticket_queue
275
276 an optional queue name for ticket additions
277
278 =item allow_pkgpart
279
280 Don't check the legality of the package definition.  This should be used
281 when performing a package change that doesn't change the pkgpart (i.e. 
282 a location change).
283
284 =back
285
286 =cut
287
288 sub insert {
289   my( $self, %options ) = @_;
290
291   my $error;
292   $error = $self->check_pkgpart unless $options{'allow_pkgpart'};
293   return $error if $error;
294
295   my $part_pkg = $self->part_pkg;
296
297   # if the package def says to start only on the first of the month:
298   if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) {
299     my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
300     $mon += 1 unless $mday == 1;
301     until ( $mon < 12 ) { $mon -= 12; $year++; }
302     $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
303   }
304
305   # set up any automatic expire/adjourn/contract_end timers
306   # based on the start date
307   foreach my $action ( qw(expire adjourn contract_end) ) {
308     my $months = $part_pkg->option("${action}_months",1);
309     if($months and !$self->$action) {
310       my $start = $self->start_date || $self->setup || time;
311       $self->$action( $part_pkg->add_freq($start, $months) );
312     }
313   }
314
315   # if this package has "free days" and delayed setup fee, tehn 
316   # set start date that many days in the future.
317   # (this should have been set in the UI, but enforce it here)
318   if (    ! $options{'change'}
319        && ( my $free_days = $part_pkg->option('free_days',1) )
320        && $part_pkg->option('delay_setup',1)
321        #&& ! $self->start_date
322      )
323   {
324     $self->start_date( $part_pkg->default_start_date );
325   }
326
327   $self->order_date(time);
328
329   local $SIG{HUP} = 'IGNORE';
330   local $SIG{INT} = 'IGNORE';
331   local $SIG{QUIT} = 'IGNORE';
332   local $SIG{TERM} = 'IGNORE';
333   local $SIG{TSTP} = 'IGNORE';
334   local $SIG{PIPE} = 'IGNORE';
335
336   my $oldAutoCommit = $FS::UID::AutoCommit;
337   local $FS::UID::AutoCommit = 0;
338   my $dbh = dbh;
339
340   $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
341   if ( $error ) {
342     $dbh->rollback if $oldAutoCommit;
343     return $error;
344   }
345
346   $self->refnum($self->cust_main->refnum) unless $self->refnum;
347   $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
348   $self->process_m2m( 'link_table'   => 'pkg_referral',
349                       'target_table' => 'part_referral',
350                       'params'       => $self->refnum,
351                     );
352
353   if ( $self->discountnum ) {
354     my $error = $self->insert_discount();
355     if ( $error ) {
356       $dbh->rollback if $oldAutoCommit;
357       return $error;
358     }
359   }
360
361   my $conf = new FS::Conf;
362
363   if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
364
365     #this init stuff is still inefficient, but at least its limited to 
366     # the small number (any?) folks using ticket emailing on pkg order
367
368     #eval '
369     #  use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
370     #  use RT;
371     #';
372     #die $@ if $@;
373     #
374     #RT::LoadConfig();
375     #RT::Init();
376     use FS::TicketSystem;
377     FS::TicketSystem->init();
378
379     my $q = new RT::Queue($RT::SystemUser);
380     $q->Load($options{ticket_queue}) if $options{ticket_queue};
381     my $t = new RT::Ticket($RT::SystemUser);
382     my $mime = new MIME::Entity;
383     $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
384     $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
385                 Subject => $options{ticket_subject},
386                 MIMEObj => $mime,
387               );
388     $t->AddLink( Type   => 'MemberOf',
389                  Target => 'freeside://freeside/cust_main/'. $self->custnum,
390                );
391   }
392
393   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
394     my $queue = new FS::queue {
395       'job'     => 'FS::cust_main::queueable_print',
396     };
397     $error = $queue->insert(
398       'custnum'  => $self->custnum,
399       'template' => 'welcome_letter',
400     );
401
402     if ($error) {
403       warn "can't send welcome letter: $error";
404     }
405
406   }
407
408   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
409   '';
410
411 }
412
413 =item delete
414
415 This method now works but you probably shouldn't use it.
416
417 You don't want to delete packages, because there would then be no record
418 the customer ever purchased the package.  Instead, see the cancel method and
419 hide cancelled packages.
420
421 =cut
422
423 sub delete {
424   my $self = shift;
425
426   local $SIG{HUP} = 'IGNORE';
427   local $SIG{INT} = 'IGNORE';
428   local $SIG{QUIT} = 'IGNORE';
429   local $SIG{TERM} = 'IGNORE';
430   local $SIG{TSTP} = 'IGNORE';
431   local $SIG{PIPE} = 'IGNORE';
432
433   my $oldAutoCommit = $FS::UID::AutoCommit;
434   local $FS::UID::AutoCommit = 0;
435   my $dbh = dbh;
436
437   foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
438     my $error = $cust_pkg_discount->delete;
439     if ( $error ) {
440       $dbh->rollback if $oldAutoCommit;
441       return $error;
442     }
443   }
444   #cust_bill_pkg_discount?
445
446   foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
447     my $error = $cust_pkg_detail->delete;
448     if ( $error ) {
449       $dbh->rollback if $oldAutoCommit;
450       return $error;
451     }
452   }
453
454   foreach my $cust_pkg_reason (
455     qsearchs( {
456                 'table' => 'cust_pkg_reason',
457                 'hashref' => { 'pkgnum' => $self->pkgnum },
458               }
459             )
460   ) {
461     my $error = $cust_pkg_reason->delete;
462     if ( $error ) {
463       $dbh->rollback if $oldAutoCommit;
464       return $error;
465     }
466   }
467
468   #pkg_referral?
469
470   my $error = $self->SUPER::delete(@_);
471   if ( $error ) {
472     $dbh->rollback if $oldAutoCommit;
473     return $error;
474   }
475
476   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
477
478   '';
479
480 }
481
482 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
483
484 Replaces the OLD_RECORD with this one in the database.  If there is an error,
485 returns the error, otherwise returns false.
486
487 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
488
489 Changing pkgpart may have disasterous effects.  See the order subroutine.
490
491 setup and bill are normally updated by calling the bill method of a customer
492 object (see L<FS::cust_main>).
493
494 suspend is normally updated by the suspend and unsuspend methods.
495
496 cancel is normally updated by the cancel method (and also the order subroutine
497 in some cases).
498
499 Available options are:
500
501 =over 4
502
503 =item reason
504
505 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.
506
507 =item reason_otaker
508
509 the access_user (see L<FS::access_user>) providing the reason
510
511 =item options
512
513 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
514
515 =back
516
517 =cut
518
519 sub replace {
520   my $new = shift;
521
522   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
523               ? shift
524               : $new->replace_old;
525
526   my $options = 
527     ( ref($_[0]) eq 'HASH' )
528       ? shift
529       : { @_ };
530
531   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
532   #return "Can't change otaker!" if $old->otaker ne $new->otaker;
533
534   #allow this *sigh*
535   #return "Can't change setup once it exists!"
536   #  if $old->getfield('setup') &&
537   #     $old->getfield('setup') != $new->getfield('setup');
538
539   #some logic for bill, susp, cancel?
540
541   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
542
543   local $SIG{HUP} = 'IGNORE';
544   local $SIG{INT} = 'IGNORE';
545   local $SIG{QUIT} = 'IGNORE';
546   local $SIG{TERM} = 'IGNORE';
547   local $SIG{TSTP} = 'IGNORE';
548   local $SIG{PIPE} = 'IGNORE';
549
550   my $oldAutoCommit = $FS::UID::AutoCommit;
551   local $FS::UID::AutoCommit = 0;
552   my $dbh = dbh;
553
554   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
555     if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
556       my $error = $new->insert_reason(
557         'reason'        => $options->{'reason'},
558         'date'          => $new->$method,
559         'action'        => $method,
560         'reason_otaker' => $options->{'reason_otaker'},
561       );
562       if ( $error ) {
563         dbh->rollback if $oldAutoCommit;
564         return "Error inserting cust_pkg_reason: $error";
565       }
566     }
567   }
568
569   #save off and freeze RADIUS attributes for any associated svc_acct records
570   my @svc_acct = ();
571   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
572
573                 #also check for specific exports?
574                 # to avoid spurious modify export events
575     @svc_acct = map  { $_->svc_x }
576                 grep { $_->part_svc->svcdb eq 'svc_acct' }
577                      $old->cust_svc;
578
579     $_->snapshot foreach @svc_acct;
580
581   }
582
583   my $error =  $new->export_pkg_change($old)
584             || $new->SUPER::replace( $old,
585                                      $options->{options}
586                                        ? $options->{options}
587                                        : ()
588                                    );
589   if ( $error ) {
590     $dbh->rollback if $oldAutoCommit;
591     return $error;
592   }
593
594   #for prepaid packages,
595   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
596   foreach my $old_svc_acct ( @svc_acct ) {
597     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
598     my $s_error =
599       $new_svc_acct->replace( $old_svc_acct,
600                               'depend_jobnum' => $options->{depend_jobnum},
601                             );
602     if ( $s_error ) {
603       $dbh->rollback if $oldAutoCommit;
604       return $s_error;
605     }
606   }
607
608   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
609   '';
610
611 }
612
613 =item check
614
615 Checks all fields to make sure this is a valid billing item.  If there is an
616 error, returns the error, otherwise returns false.  Called by the insert and
617 replace methods.
618
619 =cut
620
621 sub check {
622   my $self = shift;
623
624   if ( !$self->locationnum or $self->locationnum == -1 ) {
625     $self->set('locationnum', $self->cust_main->ship_locationnum);
626   }
627
628   my $error = 
629     $self->ut_numbern('pkgnum')
630     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
631     || $self->ut_numbern('pkgpart')
632     || $self->ut_foreign_keyn('contactnum',  'contact',       'contactnum' )
633     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
634     || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum')
635     || $self->ut_numbern('start_date')
636     || $self->ut_numbern('setup')
637     || $self->ut_numbern('bill')
638     || $self->ut_numbern('susp')
639     || $self->ut_numbern('cancel')
640     || $self->ut_numbern('adjourn')
641     || $self->ut_numbern('resume')
642     || $self->ut_numbern('expire')
643     || $self->ut_numbern('dundate')
644     || $self->ut_enum('no_auto', [ '', 'Y' ])
645     || $self->ut_enum('waive_setup', [ '', 'Y' ])
646     || $self->ut_numbern('agent_pkgid')
647     || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
648     || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
649     || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum')
650     || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum')
651     || $self->ut_foreign_keyn('change_to_pkgnum', 'cust_pkg', 'pkgnum')
652   ;
653   return $error if $error;
654
655   return "A package with both start date (future start) and setup date (already started) will never bill"
656     if $self->start_date && $self->setup;
657
658   return "A future unsuspend date can only be set for a package with a suspend date"
659     if $self->resume and !$self->susp and !$self->adjourn;
660
661   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
662
663   if ( $self->dbdef_table->column('manual_flag') ) {
664     $self->manual_flag('') if $self->manual_flag eq ' ';
665     $self->manual_flag =~ /^([01]?)$/
666       or return "Illegal manual_flag ". $self->manual_flag;
667     $self->manual_flag($1);
668   }
669
670   $self->SUPER::check;
671 }
672
673 =item check_pkgpart
674
675 Check the pkgpart to make sure it's allowed with the reg_code and/or
676 promo_code of the package (if present) and with the customer's agent.
677 Called from C<insert>, unless we are doing a package change that doesn't
678 affect pkgpart.
679
680 =cut
681
682 sub check_pkgpart {
683   my $self = shift;
684
685   # my $error = $self->ut_numbern('pkgpart'); # already done
686
687   my $error;
688   if ( $self->reg_code ) {
689
690     unless ( grep { $self->pkgpart == $_->pkgpart }
691              map  { $_->reg_code_pkg }
692              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
693                                      'agentnum' => $self->cust_main->agentnum })
694            ) {
695       return "Unknown registration code";
696     }
697
698   } elsif ( $self->promo_code ) {
699
700     my $promo_part_pkg =
701       qsearchs('part_pkg', {
702         'pkgpart'    => $self->pkgpart,
703         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
704       } );
705     return 'Unknown promotional code' unless $promo_part_pkg;
706
707   } else { 
708
709     unless ( $disable_agentcheck ) {
710       my $agent =
711         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
712       return "agent ". $agent->agentnum. ':'. $agent->agent.
713              " can't purchase pkgpart ". $self->pkgpart
714         unless $agent->pkgpart_hashref->{ $self->pkgpart }
715             || $agent->agentnum == $self->part_pkg->agentnum;
716     }
717
718     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
719     return $error if $error;
720
721   }
722
723   '';
724
725 }
726
727 =item cancel [ OPTION => VALUE ... ]
728
729 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
730 in this package, then cancels the package itself (sets the cancel field to
731 now).
732
733 Available options are:
734
735 =over 4
736
737 =item quiet - can be set true to supress email cancellation notices.
738
739 =item time -  can be set to cancel the package based on a specific future or 
740 historical date.  Using time ensures that the remaining amount is calculated 
741 correctly.  Note however that this is an immediate cancel and just changes 
742 the date.  You are PROBABLY looking to expire the account instead of using 
743 this.
744
745 =item reason - can be set to a cancellation reason (see L<FS:reason>), 
746 either a reasonnum of an existing reason, or passing a hashref will create 
747 a new reason.  The hashref should have the following keys: typenum - Reason 
748 type (see L<FS::reason_type>, reason - Text of the new reason.
749
750 =item date - can be set to a unix style timestamp to specify when to 
751 cancel (expire)
752
753 =item nobill - can be set true to skip billing if it might otherwise be done.
754
755 =item unused_credit - can be set to 1 to credit the remaining time, or 0 to 
756 not credit it.  This must be set (by change()) when changing the package 
757 to a different pkgpart or location, and probably shouldn't be in any other 
758 case.  If it's not set, the 'unused_credit_cancel' part_pkg option will 
759 be used.
760
761 =back
762
763 If there is an error, returns the error, otherwise returns false.
764
765 =cut
766
767 sub cancel {
768   my( $self, %options ) = @_;
769   my $error;
770
771   # pass all suspend/cancel actions to the main package
772   if ( $self->main_pkgnum and !$options{'from_main'} ) {
773     return $self->main_pkg->cancel(%options);
774   }
775
776   my $conf = new FS::Conf;
777
778   warn "cust_pkg::cancel called with options".
779        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
780     if $DEBUG;
781
782   local $SIG{HUP} = 'IGNORE';
783   local $SIG{INT} = 'IGNORE';
784   local $SIG{QUIT} = 'IGNORE'; 
785   local $SIG{TERM} = 'IGNORE';
786   local $SIG{TSTP} = 'IGNORE';
787   local $SIG{PIPE} = 'IGNORE';
788
789   my $oldAutoCommit = $FS::UID::AutoCommit;
790   local $FS::UID::AutoCommit = 0;
791   my $dbh = dbh;
792   
793   my $old = $self->select_for_update;
794
795   if ( $old->get('cancel') || $self->get('cancel') ) {
796     dbh->rollback if $oldAutoCommit;
797     return "";  # no error
798   }
799
800   # XXX possibly set cancel_time to the expire date?
801   my $cancel_time = $options{'time'} || time;
802   my $date = $options{'date'} if $options{'date'}; # expire/cancel later
803   $date = '' if ($date && $date <= $cancel_time);      # complain instead?
804
805   #race condition: usage could be ongoing until unprovisioned
806   #resolved by performing a change package instead (which unprovisions) and
807   #later cancelling
808   if ( !$options{nobill} && !$date ) {
809     # && $conf->exists('bill_usage_on_cancel') ) { #calc_cancel checks this
810       my $copy = $self->new({$self->hash});
811       my $error =
812         $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
813                                 'cancel'   => 1,
814                                 'time'     => $cancel_time );
815       warn "Error billing during cancel, custnum ".
816         #$self->cust_main->custnum. ": $error"
817         ": $error"
818         if $error;
819   }
820
821   if ( $options{'reason'} ) {
822     $error = $self->insert_reason( 'reason' => $options{'reason'},
823                                    'action' => $date ? 'expire' : 'cancel',
824                                    'date'   => $date ? $date : $cancel_time,
825                                    'reason_otaker' => $options{'reason_otaker'},
826                                  );
827     if ( $error ) {
828       dbh->rollback if $oldAutoCommit;
829       return "Error inserting cust_pkg_reason: $error";
830     }
831   }
832
833   my %svc_cancel_opt = ();
834   $svc_cancel_opt{'date'} = $date if $date;
835   foreach my $cust_svc (
836     #schwartz
837     map  { $_->[0] }
838     sort { $a->[1] <=> $b->[1] }
839     map  { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; }
840     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
841   ) {
842     my $part_svc = $cust_svc->part_svc;
843     next if ( defined($part_svc) and $part_svc->preserve );
844     my $error = $cust_svc->cancel( %svc_cancel_opt );
845
846     if ( $error ) {
847       $dbh->rollback if $oldAutoCommit;
848       return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
849              " cust_svc: $error";
850     }
851   }
852
853   unless ($date) {
854     # credit remaining time if appropriate
855     my $do_credit;
856     if ( exists($options{'unused_credit'}) ) {
857       $do_credit = $options{'unused_credit'};
858     }
859     else {
860       $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
861     }
862     if ( $do_credit ) {
863       my $error = $self->credit_remaining('cancel', $cancel_time);
864       if ($error) {
865         $dbh->rollback if $oldAutoCommit;
866         return $error;
867       }
868     }
869
870   } #unless $date
871
872   my %hash = $self->hash;
873   if ( $date ) {
874     $hash{'expire'} = $date;
875   } else {
876     $hash{'cancel'} = $cancel_time;
877   }
878   $hash{'change_custnum'} = $options{'change_custnum'};
879
880   my $new = new FS::cust_pkg ( \%hash );
881   $error = $new->replace( $self, options => { $self->options } );
882   if ( $self->change_to_pkgnum ) {
883     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
884     $error ||= $change_to->cancel || $change_to->delete;
885   }
886   if ( $error ) {
887     $dbh->rollback if $oldAutoCommit;
888     return $error;
889   }
890
891   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
892     $error = $supp_pkg->cancel(%options, 'from_main' => 1);
893     if ( $error ) {
894       $dbh->rollback if $oldAutoCommit;
895       return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
896     }
897   }
898
899   foreach my $usage ( $self->cust_pkg_usage ) {
900     $error = $usage->delete;
901     if ( $error ) {
902       $dbh->rollback if $oldAutoCommit;
903       return "deleting usage pools: $error";
904     }
905   }
906
907   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
908   return '' if $date; #no errors
909
910   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
911   if ( !$options{'quiet'} && 
912         $conf->exists('emailcancel', $self->cust_main->agentnum) && 
913         @invoicing_list ) {
914     my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
915     my $error = '';
916     if ( $msgnum ) {
917       my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
918       $error = $msg_template->send( 'cust_main' => $self->cust_main,
919                                     'object'    => $self );
920     }
921     else {
922       $error = send_email(
923         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
924         'to'      => \@invoicing_list,
925         'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
926         'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
927       );
928     }
929     #should this do something on errors?
930   }
931
932   ''; #no errors
933
934 }
935
936 =item cancel_if_expired [ NOW_TIMESTAMP ]
937
938 Cancels this package if its expire date has been reached.
939
940 =cut
941
942 sub cancel_if_expired {
943   my $self = shift;
944   my $time = shift || time;
945   return '' unless $self->expire && $self->expire <= $time;
946   my $error = $self->cancel;
947   if ( $error ) {
948     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
949            $self->custnum. ": $error";
950   }
951   '';
952 }
953
954 =item uncancel
955
956 "Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
957 locationnum, (other fields?).  Attempts to re-provision cancelled services
958 using history information (errors at this stage are not fatal).
959
960 cust_pkg: pass a scalar reference, will be filled in with the new cust_pkg object
961
962 svc_fatal: service provisioning errors are fatal
963
964 svc_errors: pass an array reference, will be filled in with any provisioning errors
965
966 main_pkgnum: link the package as a supplemental package of this one.  For 
967 internal use only.
968
969 =cut
970
971 sub uncancel {
972   my( $self, %options ) = @_;
973
974   #in case you try do do $uncancel-date = $cust_pkg->uncacel 
975   return '' unless $self->get('cancel');
976
977   if ( $self->main_pkgnum and !$options{'main_pkgnum'} ) {
978     return $self->main_pkg->uncancel(%options);
979   }
980
981   ##
982   # Transaction-alize
983   ##
984
985   local $SIG{HUP} = 'IGNORE';
986   local $SIG{INT} = 'IGNORE'; 
987   local $SIG{QUIT} = 'IGNORE';
988   local $SIG{TERM} = 'IGNORE';
989   local $SIG{TSTP} = 'IGNORE'; 
990   local $SIG{PIPE} = 'IGNORE'; 
991
992   my $oldAutoCommit = $FS::UID::AutoCommit;
993   local $FS::UID::AutoCommit = 0;
994   my $dbh = dbh;
995
996   ##
997   # insert the new package
998   ##
999
1000   my $cust_pkg = new FS::cust_pkg {
1001     last_bill       => ( $options{'last_bill'} || $self->get('last_bill') ),
1002     bill            => ( $options{'bill'}      || $self->get('bill')      ),
1003     uncancel        => time,
1004     uncancel_pkgnum => $self->pkgnum,
1005     main_pkgnum     => ($options{'main_pkgnum'} || ''),
1006     map { $_ => $self->get($_) } qw(
1007       custnum pkgpart locationnum
1008       setup
1009       susp adjourn resume expire start_date contract_end dundate
1010       change_date change_pkgpart change_locationnum
1011       manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
1012     ),
1013   };
1014
1015   my $error = $cust_pkg->insert(
1016     'change' => 1, #supresses any referral credit to a referring customer
1017     'allow_pkgpart' => 1, # allow this even if the package def is disabled
1018   );
1019   if ($error) {
1020     $dbh->rollback if $oldAutoCommit;
1021     return $error;
1022   }
1023
1024   ##
1025   # insert services
1026   ##
1027
1028   #find historical services within this timeframe before the package cancel
1029   # (incompatible with "time" option to cust_pkg->cancel?)
1030   my $fuzz = 2 * 60; #2 minutes?  too much?   (might catch separate unprovision)
1031                      #            too little? (unprovisioing export delay?)
1032   my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
1033   my @h_cust_svc = $self->h_cust_svc( $end, $start );
1034
1035   my @svc_errors;
1036   foreach my $h_cust_svc (@h_cust_svc) {
1037     my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
1038     #next unless $h_svc_x; #should this happen?
1039     (my $table = $h_svc_x->table) =~ s/^h_//;
1040     require "FS/$table.pm";
1041     my $class = "FS::$table";
1042     my $svc_x = $class->new( {
1043       'pkgnum'  => $cust_pkg->pkgnum,
1044       'svcpart' => $h_cust_svc->svcpart,
1045       map { $_ => $h_svc_x->get($_) } fields($table)
1046     } );
1047
1048     # radius_usergroup
1049     if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
1050       $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
1051     }
1052
1053     my $svc_error = $svc_x->insert;
1054     if ( $svc_error ) {
1055       if ( $options{svc_fatal} ) {
1056         $dbh->rollback if $oldAutoCommit;
1057         return $svc_error;
1058       } else {
1059         # if we've failed to insert the svc_x object, svc_Common->insert 
1060         # will have removed the cust_svc already.  if not, then both records
1061         # were inserted but we failed for some other reason (export, most 
1062         # likely).  in that case, report the error and delete the records.
1063         push @svc_errors, $svc_error;
1064         my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
1065         if ( $cust_svc ) {
1066           # except if export_insert failed, export_delete probably won't be
1067           # much better
1068           local $FS::svc_Common::noexport_hack = 1;
1069           my $cleanup_error = $svc_x->delete; # also deletes cust_svc
1070           if ( $cleanup_error ) { # and if THAT fails, then run away
1071             $dbh->rollback if $oldAutoCommit;
1072             return $cleanup_error;
1073           }
1074         }
1075       } # svc_fatal
1076     } # svc_error
1077   } #foreach $h_cust_svc
1078
1079   #these are pretty rare, but should handle them
1080   # - dsl_device (mac addresses)
1081   # - phone_device (mac addresses)
1082   # - dsl_note (ikano notes)
1083   # - domain_record (i.e. restore DNS information w/domains)
1084   # - inventory_item(?) (inventory w/un-cancelling service?)
1085   # - nas (svc_broaband nas stuff)
1086   #this stuff is unused in the wild afaik
1087   # - mailinglistmember
1088   # - router.svcnum?
1089   # - svc_domain.parent_svcnum?
1090   # - acct_snarf (ancient mail fetching config)
1091   # - cgp_rule (communigate)
1092   # - cust_svc_option (used by our Tron stuff)
1093   # - acct_rt_transaction (used by our time worked stuff)
1094
1095   ##
1096   # also move over any services that didn't unprovision at cancellation
1097   ## 
1098
1099   foreach my $cust_svc ( qsearch('cust_svc', { pkgnum => $self->pkgnum } ) ) {
1100     $cust_svc->pkgnum( $cust_pkg->pkgnum );
1101     my $error = $cust_svc->replace;
1102     if ( $error ) {
1103       $dbh->rollback if $oldAutoCommit;
1104       return $error;
1105     }
1106   }
1107
1108   ##
1109   # Uncancel any supplemental packages, and make them supplemental to the 
1110   # new one.
1111   ##
1112
1113   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1114     my $new_pkg;
1115     $error = $supp_pkg->uncancel(%options, 'main_pkgnum' => $cust_pkg->pkgnum);
1116     if ( $error ) {
1117       $dbh->rollback if $oldAutoCommit;
1118       return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
1119     }
1120   }
1121
1122   ##
1123   # Finish
1124   ##
1125
1126   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1127
1128   ${ $options{cust_pkg} }   = $cust_pkg   if ref($options{cust_pkg});
1129   @{ $options{svc_errors} } = @svc_errors if ref($options{svc_errors});
1130
1131   '';
1132 }
1133
1134 =item unexpire
1135
1136 Cancels any pending expiration (sets the expire field to null).
1137
1138 If there is an error, returns the error, otherwise returns false.
1139
1140 =cut
1141
1142 sub unexpire {
1143   my( $self, %options ) = @_;
1144   my $error;
1145
1146   local $SIG{HUP} = 'IGNORE';
1147   local $SIG{INT} = 'IGNORE';
1148   local $SIG{QUIT} = 'IGNORE';
1149   local $SIG{TERM} = 'IGNORE';
1150   local $SIG{TSTP} = 'IGNORE';
1151   local $SIG{PIPE} = 'IGNORE';
1152
1153   my $oldAutoCommit = $FS::UID::AutoCommit;
1154   local $FS::UID::AutoCommit = 0;
1155   my $dbh = dbh;
1156
1157   my $old = $self->select_for_update;
1158
1159   my $pkgnum = $old->pkgnum;
1160   if ( $old->get('cancel') || $self->get('cancel') ) {
1161     dbh->rollback if $oldAutoCommit;
1162     return "Can't unexpire cancelled package $pkgnum";
1163     # or at least it's pointless
1164   }
1165
1166   unless ( $old->get('expire') && $self->get('expire') ) {
1167     dbh->rollback if $oldAutoCommit;
1168     return "";  # no error
1169   }
1170
1171   my %hash = $self->hash;
1172   $hash{'expire'} = '';
1173   my $new = new FS::cust_pkg ( \%hash );
1174   $error = $new->replace( $self, options => { $self->options } );
1175   if ( $error ) {
1176     $dbh->rollback if $oldAutoCommit;
1177     return $error;
1178   }
1179
1180   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1181
1182   ''; #no errors
1183
1184 }
1185
1186 =item suspend [ OPTION => VALUE ... ]
1187
1188 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1189 package, then suspends the package itself (sets the susp field to now).
1190
1191 Available options are:
1192
1193 =over 4
1194
1195 =item reason - can be set to a cancellation reason (see L<FS:reason>), 
1196 either a reasonnum of an existing reason, or passing a hashref will create 
1197 a new reason.  The hashref should have the following keys: 
1198 - typenum - Reason type (see L<FS::reason_type>
1199 - reason - Text of the new reason.
1200
1201 =item date - can be set to a unix style timestamp to specify when to 
1202 suspend (adjourn)
1203
1204 =item time - can be set to override the current time, for calculation 
1205 of final invoices or unused-time credits
1206
1207 =item resume_date - can be set to a time when the package should be 
1208 unsuspended.  This may be more convenient than calling C<unsuspend()>
1209 separately.
1210
1211 =item from_main - allows a supplemental package to be suspended, rather
1212 than redirecting the method call to its main package.  For internal use.
1213
1214 =back
1215
1216 If there is an error, returns the error, otherwise returns false.
1217
1218 =cut
1219
1220 sub suspend {
1221   my( $self, %options ) = @_;
1222   my $error;
1223
1224   # pass all suspend/cancel actions to the main package
1225   if ( $self->main_pkgnum and !$options{'from_main'} ) {
1226     return $self->main_pkg->suspend(%options);
1227   }
1228
1229   local $SIG{HUP} = 'IGNORE';
1230   local $SIG{INT} = 'IGNORE';
1231   local $SIG{QUIT} = 'IGNORE'; 
1232   local $SIG{TERM} = 'IGNORE';
1233   local $SIG{TSTP} = 'IGNORE';
1234   local $SIG{PIPE} = 'IGNORE';
1235
1236   my $oldAutoCommit = $FS::UID::AutoCommit;
1237   local $FS::UID::AutoCommit = 0;
1238   my $dbh = dbh;
1239
1240   my $old = $self->select_for_update;
1241
1242   my $pkgnum = $old->pkgnum;
1243   if ( $old->get('cancel') || $self->get('cancel') ) {
1244     dbh->rollback if $oldAutoCommit;
1245     return "Can't suspend cancelled package $pkgnum";
1246   }
1247
1248   if ( $old->get('susp') || $self->get('susp') ) {
1249     dbh->rollback if $oldAutoCommit;
1250     return "";  # no error                     # complain on adjourn?
1251   }
1252
1253   my $suspend_time = $options{'time'} || time;
1254   my $date = $options{date} if $options{date}; # adjourn/suspend later
1255   $date = '' if ($date && $date <= $suspend_time); # complain instead?
1256
1257   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
1258     dbh->rollback if $oldAutoCommit;
1259     return "Package $pkgnum expires before it would be suspended.";
1260   }
1261
1262   # some false laziness with sub cancel
1263   if ( !$options{nobill} && !$date &&
1264        $self->part_pkg->option('bill_suspend_as_cancel',1) ) {
1265     # kind of a kludge--'bill_suspend_as_cancel' to avoid having to 
1266     # make the entire cust_main->bill path recognize 'suspend' and 
1267     # 'cancel' separately.
1268     warn "Billing $pkgnum on suspension (at $suspend_time)\n" if $DEBUG;
1269     my $copy = $self->new({$self->hash});
1270     my $error =
1271       $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
1272                               'cancel'   => 1,
1273                               'time'     => $suspend_time );
1274     warn "Error billing during suspend, custnum ".
1275       #$self->cust_main->custnum. ": $error"
1276       ": $error"
1277       if $error;
1278   }
1279
1280   if ( $options{'reason'} ) {
1281     $error = $self->insert_reason( 'reason' => $options{'reason'},
1282                                    'action' => $date ? 'adjourn' : 'suspend',
1283                                    'date'   => $date ? $date : $suspend_time,
1284                                    'reason_otaker' => $options{'reason_otaker'},
1285                                  );
1286     if ( $error ) {
1287       dbh->rollback if $oldAutoCommit;
1288       return "Error inserting cust_pkg_reason: $error";
1289     }
1290   }
1291
1292   my %hash = $self->hash;
1293   if ( $date ) {
1294     $hash{'adjourn'} = $date;
1295   } else {
1296     $hash{'susp'} = $suspend_time;
1297   }
1298
1299   my $resume_date = $options{'resume_date'} || 0;
1300   if ( $resume_date > ($date || $suspend_time) ) {
1301     $hash{'resume'} = $resume_date;
1302   }
1303
1304   $options{options} ||= {};
1305
1306   my $new = new FS::cust_pkg ( \%hash );
1307   $error = $new->replace( $self, options => { $self->options,
1308                                               %{ $options{options} },
1309                                             }
1310                         );
1311   if ( $error ) {
1312     $dbh->rollback if $oldAutoCommit;
1313     return $error;
1314   }
1315
1316   unless ( $date ) {
1317     # credit remaining time if appropriate
1318     if ( $self->part_pkg->option('unused_credit_suspend', 1) ) {
1319       my $error = $self->credit_remaining('suspend', $suspend_time);
1320       if ($error) {
1321         $dbh->rollback if $oldAutoCommit;
1322         return $error;
1323       }
1324     }
1325
1326     my @labels = ();
1327
1328     foreach my $cust_svc (
1329       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
1330     ) {
1331       my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1332
1333       $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1334         $dbh->rollback if $oldAutoCommit;
1335         return "Illegal svcdb value in part_svc!";
1336       };
1337       my $svcdb = $1;
1338       require "FS/$svcdb.pm";
1339
1340       my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1341       if ($svc) {
1342         $error = $svc->suspend;
1343         if ( $error ) {
1344           $dbh->rollback if $oldAutoCommit;
1345           return $error;
1346         }
1347         my( $label, $value ) = $cust_svc->label;
1348         push @labels, "$label: $value";
1349       }
1350     }
1351
1352     my $conf = new FS::Conf;
1353     if ( $conf->config('suspend_email_admin') ) {
1354  
1355       my $error = send_email(
1356         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1357                                    #invoice_from ??? well as good as any
1358         'to'      => $conf->config('suspend_email_admin'),
1359         'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
1360         'body'    => [
1361           "This is an automatic message from your Freeside installation\n",
1362           "informing you that the following customer package has been suspended:\n",
1363           "\n",
1364           'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1365           'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1366           ( map { "Service : $_\n" } @labels ),
1367         ],
1368       );
1369
1370       if ( $error ) {
1371         warn "WARNING: can't send suspension admin email (suspending anyway): ".
1372              "$error\n";
1373       }
1374
1375     }
1376
1377   }
1378
1379   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1380     $error = $supp_pkg->suspend(%options, 'from_main' => 1);
1381     if ( $error ) {
1382       $dbh->rollback if $oldAutoCommit;
1383       return "suspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
1384     }
1385   }
1386
1387   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1388
1389   ''; #no errors
1390 }
1391
1392 =item credit_remaining MODE TIME
1393
1394 Generate a credit for this package for the time remaining in the current 
1395 billing period.  MODE is either "suspend" or "cancel" (determines the 
1396 credit type).  TIME is the time of suspension/cancellation.  Both arguments
1397 are mandatory.
1398
1399 =cut
1400
1401 sub credit_remaining {
1402   # Add a credit for remaining service
1403   my ($self, $mode, $time) = @_;
1404   die 'credit_remaining requires suspend or cancel' 
1405     unless $mode eq 'suspend' or $mode eq 'cancel';
1406   die 'no suspend/cancel time' unless $time > 0;
1407
1408   my $conf = FS::Conf->new;
1409   my $reason_type = $conf->config($mode.'_credit_type');
1410
1411   my $last_bill = $self->getfield('last_bill') || 0;
1412   my $next_bill = $self->getfield('bill') || 0;
1413   if ( $last_bill > 0         # the package has been billed
1414       and $next_bill > 0      # the package has a next bill date
1415       and $next_bill >= $time # which is in the future
1416   ) {
1417     my $remaining_value = $self->calc_remain('time' => $time);
1418     if ( $remaining_value > 0 ) {
1419       warn "Crediting for $remaining_value on package ".$self->pkgnum."\n"
1420         if $DEBUG;
1421       my $error = $self->cust_main->credit(
1422         $remaining_value,
1423         'Credit for unused time on '. $self->part_pkg->pkg,
1424         'reason_type' => $reason_type,
1425       );
1426       return "Error crediting customer \$$remaining_value for unused time".
1427         " on ". $self->part_pkg->pkg. ": $error"
1428         if $error;
1429     } #if $remaining_value
1430   } #if $last_bill, etc.
1431   '';
1432 }
1433
1434 =item unsuspend [ OPTION => VALUE ... ]
1435
1436 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1437 package, then unsuspends the package itself (clears the susp field and the
1438 adjourn field if it is in the past).  If the suspend reason includes an 
1439 unsuspension package, that package will be ordered.
1440
1441 Available options are:
1442
1443 =over 4
1444
1445 =item date
1446
1447 Can be set to a date to unsuspend the package in the future (the 'resume' 
1448 field).
1449
1450 =item adjust_next_bill
1451
1452 Can be set true to adjust the next bill date forward by
1453 the amount of time the account was inactive.  This was set true by default
1454 in the past (from 1.4.2 and 1.5.0pre6 through 1.7.0), but now needs to be
1455 explicitly requested with this option or in the price plan.
1456
1457 =back
1458
1459 If there is an error, returns the error, otherwise returns false.
1460
1461 =cut
1462
1463 sub unsuspend {
1464   my( $self, %opt ) = @_;
1465   my $error;
1466
1467   # pass all suspend/cancel actions to the main package
1468   if ( $self->main_pkgnum and !$opt{'from_main'} ) {
1469     return $self->main_pkg->unsuspend(%opt);
1470   }
1471
1472   local $SIG{HUP} = 'IGNORE';
1473   local $SIG{INT} = 'IGNORE';
1474   local $SIG{QUIT} = 'IGNORE'; 
1475   local $SIG{TERM} = 'IGNORE';
1476   local $SIG{TSTP} = 'IGNORE';
1477   local $SIG{PIPE} = 'IGNORE';
1478
1479   my $oldAutoCommit = $FS::UID::AutoCommit;
1480   local $FS::UID::AutoCommit = 0;
1481   my $dbh = dbh;
1482
1483   my $old = $self->select_for_update;
1484
1485   my $pkgnum = $old->pkgnum;
1486   if ( $old->get('cancel') || $self->get('cancel') ) {
1487     $dbh->rollback if $oldAutoCommit;
1488     return "Can't unsuspend cancelled package $pkgnum";
1489   }
1490
1491   unless ( $old->get('susp') && $self->get('susp') ) {
1492     $dbh->rollback if $oldAutoCommit;
1493     return "";  # no error                     # complain instead?
1494   }
1495
1496   my $date = $opt{'date'};
1497   if ( $date and $date > time ) { # return an error if $date <= time?
1498
1499     if ( $old->get('expire') && $old->get('expire') < $date ) {
1500       $dbh->rollback if $oldAutoCommit;
1501       return "Package $pkgnum expires before it would be unsuspended.";
1502     }
1503
1504     my $new = new FS::cust_pkg { $self->hash };
1505     $new->set('resume', $date);
1506     $error = $new->replace($self, options => $self->options);
1507
1508     if ( $error ) {
1509       $dbh->rollback if $oldAutoCommit;
1510       return $error;
1511     }
1512     else {
1513       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1514       return '';
1515     }
1516   
1517   } #if $date 
1518
1519   my @labels = ();
1520
1521   foreach my $cust_svc (
1522     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
1523   ) {
1524     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1525
1526     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1527       $dbh->rollback if $oldAutoCommit;
1528       return "Illegal svcdb value in part_svc!";
1529     };
1530     my $svcdb = $1;
1531     require "FS/$svcdb.pm";
1532
1533     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1534     if ($svc) {
1535       $error = $svc->unsuspend;
1536       if ( $error ) {
1537         $dbh->rollback if $oldAutoCommit;
1538         return $error;
1539       }
1540       my( $label, $value ) = $cust_svc->label;
1541       push @labels, "$label: $value";
1542     }
1543
1544   }
1545
1546   my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
1547   my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
1548
1549   my %hash = $self->hash;
1550   my $inactive = time - $hash{'susp'};
1551
1552   my $conf = new FS::Conf;
1553
1554   if ( $inactive > 0 && 
1555        ( $hash{'bill'} || $hash{'setup'} ) &&
1556        ( $opt{'adjust_next_bill'} ||
1557          $conf->exists('unsuspend-always_adjust_next_bill_date') ||
1558          $self->part_pkg->option('unsuspend_adjust_bill', 1) )
1559      ) {
1560
1561     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
1562   
1563   }
1564
1565   $hash{'susp'} = '';
1566   $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time;
1567   $hash{'resume'} = '' if !$hash{'adjourn'};
1568   my $new = new FS::cust_pkg ( \%hash );
1569   $error = $new->replace( $self, options => { $self->options } );
1570   if ( $error ) {
1571     $dbh->rollback if $oldAutoCommit;
1572     return $error;
1573   }
1574
1575   my $unsusp_pkg;
1576
1577   if ( $reason && $reason->unsuspend_pkgpart ) {
1578     my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
1579       or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
1580                   " not found.";
1581     my $start_date = $self->cust_main->next_bill_date 
1582       if $reason->unsuspend_hold;
1583
1584     if ( $part_pkg ) {
1585       $unsusp_pkg = FS::cust_pkg->new({
1586           'custnum'     => $self->custnum,
1587           'pkgpart'     => $reason->unsuspend_pkgpart,
1588           'start_date'  => $start_date,
1589           'locationnum' => $self->locationnum,
1590           # discount? probably not...
1591       });
1592       
1593       $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
1594     }
1595
1596     if ( $error ) {
1597       $dbh->rollback if $oldAutoCommit;
1598       return $error;
1599     }
1600   }
1601
1602   if ( $conf->config('unsuspend_email_admin') ) {
1603  
1604     my $error = send_email(
1605       'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1606                                  #invoice_from ??? well as good as any
1607       'to'      => $conf->config('unsuspend_email_admin'),
1608       'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended',       'body'    => [
1609         "This is an automatic message from your Freeside installation\n",
1610         "informing you that the following customer package has been unsuspended:\n",
1611         "\n",
1612         'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1613         'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1614         ( map { "Service : $_\n" } @labels ),
1615         ($unsusp_pkg ?
1616           "An unsuspension fee was charged: ".
1617             $unsusp_pkg->part_pkg->pkg_comment."\n"
1618           : ''
1619         ),
1620       ],
1621     );
1622
1623     if ( $error ) {
1624       warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
1625            "$error\n";
1626     }
1627
1628   }
1629
1630   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1631     $error = $supp_pkg->unsuspend(%opt, 'from_main' => 1);
1632     if ( $error ) {
1633       $dbh->rollback if $oldAutoCommit;
1634       return "unsuspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
1635     }
1636   }
1637
1638   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1639
1640   ''; #no errors
1641 }
1642
1643 =item unadjourn
1644
1645 Cancels any pending suspension (sets the adjourn field to null).
1646
1647 If there is an error, returns the error, otherwise returns false.
1648
1649 =cut
1650
1651 sub unadjourn {
1652   my( $self, %options ) = @_;
1653   my $error;
1654
1655   local $SIG{HUP} = 'IGNORE';
1656   local $SIG{INT} = 'IGNORE';
1657   local $SIG{QUIT} = 'IGNORE'; 
1658   local $SIG{TERM} = 'IGNORE';
1659   local $SIG{TSTP} = 'IGNORE';
1660   local $SIG{PIPE} = 'IGNORE';
1661
1662   my $oldAutoCommit = $FS::UID::AutoCommit;
1663   local $FS::UID::AutoCommit = 0;
1664   my $dbh = dbh;
1665
1666   my $old = $self->select_for_update;
1667
1668   my $pkgnum = $old->pkgnum;
1669   if ( $old->get('cancel') || $self->get('cancel') ) {
1670     dbh->rollback if $oldAutoCommit;
1671     return "Can't unadjourn cancelled package $pkgnum";
1672     # or at least it's pointless
1673   }
1674
1675   if ( $old->get('susp') || $self->get('susp') ) {
1676     dbh->rollback if $oldAutoCommit;
1677     return "Can't unadjourn suspended package $pkgnum";
1678     # perhaps this is arbitrary
1679   }
1680
1681   unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1682     dbh->rollback if $oldAutoCommit;
1683     return "";  # no error
1684   }
1685
1686   my %hash = $self->hash;
1687   $hash{'adjourn'} = '';
1688   $hash{'resume'}  = '';
1689   my $new = new FS::cust_pkg ( \%hash );
1690   $error = $new->replace( $self, options => { $self->options } );
1691   if ( $error ) {
1692     $dbh->rollback if $oldAutoCommit;
1693     return $error;
1694   }
1695
1696   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1697
1698   ''; #no errors
1699
1700 }
1701
1702
1703 =item change HASHREF | OPTION => VALUE ... 
1704
1705 Changes this package: cancels it and creates a new one, with a different
1706 pkgpart or locationnum or both.  All services are transferred to the new
1707 package (no change will be made if this is not possible).
1708
1709 Options may be passed as a list of key/value pairs or as a hash reference.
1710 Options are:
1711
1712 =over 4
1713
1714 =item locationnum
1715
1716 New locationnum, to change the location for this package.
1717
1718 =item cust_location
1719
1720 New FS::cust_location object, to create a new location and assign it
1721 to this package.
1722
1723 =item cust_main
1724
1725 New FS::cust_main object, to create a new customer and assign the new package
1726 to it.
1727
1728 =item pkgpart
1729
1730 New pkgpart (see L<FS::part_pkg>).
1731
1732 =item refnum
1733
1734 New refnum (see L<FS::part_referral>).
1735
1736 =item quantity
1737
1738 New quantity; if unspecified, the new package will have the same quantity
1739 as the old.
1740
1741 =item cust_pkg
1742
1743 "New" (existing) FS::cust_pkg object.  The package's services and other 
1744 attributes will be transferred to this package.
1745
1746 =item keep_dates
1747
1748 Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
1749 susp, adjourn, cancel, expire, and contract_end) to the new package.
1750
1751 =item unprotect_svcs
1752
1753 Normally, change() will rollback and return an error if some services 
1754 can't be transferred (also see the I<cust_pkg-change_svcpart> config option).
1755 If unprotect_svcs is true, this method will transfer as many services as 
1756 it can and then unconditionally cancel the old package.
1757
1758 =back
1759
1760 At least one of locationnum, cust_location, pkgpart, refnum, cust_main, or
1761 cust_pkg must be specified (otherwise, what's the point?)
1762
1763 Returns either the new FS::cust_pkg object or a scalar error.
1764
1765 For example:
1766
1767   my $err_or_new_cust_pkg = $old_cust_pkg->change
1768
1769 =cut
1770
1771 #some false laziness w/order
1772 sub change {
1773   my $self = shift;
1774   my $opt = ref($_[0]) ? shift : { @_ };
1775
1776   my $conf = new FS::Conf;
1777
1778   # Transactionize this whole mess
1779   local $SIG{HUP} = 'IGNORE';
1780   local $SIG{INT} = 'IGNORE'; 
1781   local $SIG{QUIT} = 'IGNORE';
1782   local $SIG{TERM} = 'IGNORE';
1783   local $SIG{TSTP} = 'IGNORE'; 
1784   local $SIG{PIPE} = 'IGNORE'; 
1785
1786   my $oldAutoCommit = $FS::UID::AutoCommit;
1787   local $FS::UID::AutoCommit = 0;
1788   my $dbh = dbh;
1789
1790   my $error;
1791
1792   my %hash = (); 
1793
1794   my $time = time;
1795
1796   $hash{'setup'} = $time if $self->setup;
1797
1798   $hash{'change_date'} = $time;
1799   $hash{"change_$_"}  = $self->$_()
1800     foreach qw( pkgnum pkgpart locationnum );
1801
1802   if ( $opt->{'cust_location'} ) {
1803     $error = $opt->{'cust_location'}->find_or_insert;
1804     if ( $error ) {
1805       $dbh->rollback if $oldAutoCommit;
1806       return "inserting cust_location (transaction rolled back): $error";
1807     }
1808     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1809   }
1810
1811   if ( $opt->{'cust_pkg'} ) {
1812     # treat changing to a package with a different pkgpart as a 
1813     # pkgpart change (because it is)
1814     $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart;
1815   }
1816
1817   # whether to override pkgpart checking on the new package
1818   my $same_pkgpart = 1;
1819   if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
1820     $same_pkgpart = 0;
1821   }
1822
1823   my $unused_credit = 0;
1824   my $keep_dates = $opt->{'keep_dates'};
1825   # Special case.  If the pkgpart is changing, and the customer is
1826   # going to be credited for remaining time, don't keep setup, bill, 
1827   # or last_bill dates, and DO pass the flag to cancel() to credit 
1828   # the customer.
1829   if ( $opt->{'pkgpart'} 
1830        and $opt->{'pkgpart'} != $self->pkgpart
1831        and $self->part_pkg->option('unused_credit_change', 1) ) {
1832     $unused_credit = 1;
1833     $keep_dates = 0;
1834     $hash{$_} = '' foreach qw(setup bill last_bill);
1835   }
1836
1837   if ( $keep_dates ) {
1838     foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
1839                           resume start_date contract_end ) ) {
1840       $hash{$date} = $self->getfield($date);
1841     }
1842   }
1843   # allow $opt->{'locationnum'} = '' to specifically set it to null
1844   # (i.e. customer default location)
1845   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
1846
1847   # usually this doesn't matter.  the two cases where it does are:
1848   # 1. unused_credit_change + pkgpart change + setup fee on the new package
1849   # and
1850   # 2. (more importantly) changing a package before it's billed
1851   $hash{'waive_setup'} = $self->waive_setup;
1852
1853   my $custnum = $self->custnum;
1854   if ( $opt->{cust_main} ) {
1855     my $cust_main = $opt->{cust_main};
1856     unless ( $cust_main->custnum ) { 
1857       my $error = $cust_main->insert;
1858       if ( $error ) {
1859         $dbh->rollback if $oldAutoCommit;
1860         return "inserting cust_main (transaction rolled back): $error";
1861       }
1862     }
1863     $custnum = $cust_main->custnum;
1864   }
1865
1866   $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
1867   $hash{'quantity'} = $opt->{'quantity'} || $self->quantity;
1868
1869   my $cust_pkg;
1870   if ( $opt->{'cust_pkg'} ) {
1871     # The target package already exists; update it to show that it was 
1872     # changed from this package.
1873     $cust_pkg = $opt->{'cust_pkg'};
1874
1875     foreach ( qw( pkgnum pkgpart locationnum ) ) {
1876       $cust_pkg->set("change_$_", $self->get($_));
1877     }
1878     $cust_pkg->set('change_date', $time);
1879     $error = $cust_pkg->replace;
1880
1881   } else {
1882     # Create the new package.
1883     $cust_pkg = new FS::cust_pkg {
1884       custnum        => $custnum,
1885       pkgpart        => ( $opt->{'pkgpart'}     || $self->pkgpart      ),
1886       refnum         => ( $opt->{'refnum'}      || $self->refnum       ),
1887       locationnum    => ( $opt->{'locationnum'}                        ),
1888       %hash,
1889     };
1890     $error = $cust_pkg->insert( 'change' => 1,
1891                                 'allow_pkgpart' => $same_pkgpart );
1892   }
1893   if ($error) {
1894     $dbh->rollback if $oldAutoCommit;
1895     return $error;
1896   }
1897
1898   # Transfer services and cancel old package.
1899
1900   $error = $self->transfer($cust_pkg);
1901   if ($error and $error == 0) {
1902     # $old_pkg->transfer failed.
1903     $dbh->rollback if $oldAutoCommit;
1904     return $error;
1905   }
1906
1907   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1908     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1909     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1910     if ($error and $error == 0) {
1911       # $old_pkg->transfer failed.
1912       $dbh->rollback if $oldAutoCommit;
1913       return $error;
1914     }
1915   }
1916
1917   # We set unprotect_svcs when executing a "future package change".  It's 
1918   # not a user-interactive operation, so returning an error means the 
1919   # package change will just fail.  Rather than have that happen, we'll 
1920   # let leftover services be deleted.
1921   if ($error > 0 and !$opt->{'unprotect_svcs'}) {
1922     # Transfers were successful, but we still had services left on the old
1923     # package.  We can't change the package under this circumstances, so abort.
1924     $dbh->rollback if $oldAutoCommit;
1925     return "Unable to transfer all services from package ". $self->pkgnum;
1926   }
1927
1928   #reset usage if changing pkgpart
1929   # AND usage rollover is off (otherwise adds twice, now and at package bill)
1930   if ($self->pkgpart != $cust_pkg->pkgpart) {
1931     my $part_pkg = $cust_pkg->part_pkg;
1932     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1933                                                  ? ()
1934                                                  : ( 'null' => 1 )
1935                                    )
1936       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
1937
1938     if ($error) {
1939       $dbh->rollback if $oldAutoCommit;
1940       return "Error setting usage values: $error";
1941     }
1942   } else {
1943     # if NOT changing pkgpart, transfer any usage pools over
1944     foreach my $usage ($self->cust_pkg_usage) {
1945       $usage->set('pkgnum', $cust_pkg->pkgnum);
1946       $error = $usage->replace;
1947       if ( $error ) {
1948         $dbh->rollback if $oldAutoCommit;
1949         return "Error transferring usage pools: $error";
1950       }
1951     }
1952   }
1953
1954   # transfer discounts, if we're not changing pkgpart
1955   if ( $same_pkgpart ) {
1956     foreach my $old_discount ($self->cust_pkg_discount_active) {
1957       # don't remove the old discount, we may still need to bill that package.
1958       my $new_discount = new FS::cust_pkg_discount {
1959         'pkgnum'      => $cust_pkg->pkgnum,
1960         'discountnum' => $old_discount->discountnum,
1961         'months_used' => $old_discount->months_used,
1962       };
1963       $error = $new_discount->insert;
1964       if ( $error ) {
1965         $dbh->rollback if $oldAutoCommit;
1966         return "Error transferring discounts: $error";
1967       }
1968     }
1969   }
1970
1971   # transfer (copy) invoice details
1972   foreach my $detail ($self->cust_pkg_detail) {
1973     my $new_detail = FS::cust_pkg_detail->new({ $detail->hash });
1974     $new_detail->set('pkgdetailnum', '');
1975     $new_detail->set('pkgnum', $cust_pkg->pkgnum);
1976     $error = $new_detail->insert;
1977     if ( $error ) {
1978       $dbh->rollback if $oldAutoCommit;
1979       return "Error transferring package notes: $error";
1980     }
1981   }
1982   
1983   my @new_supp_pkgs;
1984
1985   if ( !$opt->{'cust_pkg'} ) {
1986     # Order any supplemental packages.
1987     my $part_pkg = $cust_pkg->part_pkg;
1988     my @old_supp_pkgs = $self->supplemental_pkgs;
1989     foreach my $link ($part_pkg->supp_part_pkg_link) {
1990       my $old;
1991       foreach (@old_supp_pkgs) {
1992         if ($_->pkgpart == $link->dst_pkgpart) {
1993           $old = $_;
1994           $_->pkgpart(0); # so that it can't match more than once
1995         }
1996         last if $old;
1997       }
1998       # false laziness with FS::cust_main::Packages::order_pkg
1999       my $new = FS::cust_pkg->new({
2000           pkgpart       => $link->dst_pkgpart,
2001           pkglinknum    => $link->pkglinknum,
2002           custnum       => $custnum,
2003           main_pkgnum   => $cust_pkg->pkgnum,
2004           locationnum   => $cust_pkg->locationnum,
2005           start_date    => $cust_pkg->start_date,
2006           order_date    => $cust_pkg->order_date,
2007           expire        => $cust_pkg->expire,
2008           adjourn       => $cust_pkg->adjourn,
2009           contract_end  => $cust_pkg->contract_end,
2010           refnum        => $cust_pkg->refnum,
2011           discountnum   => $cust_pkg->discountnum,
2012           waive_setup   => $cust_pkg->waive_setup,
2013       });
2014       if ( $old and $opt->{'keep_dates'} ) {
2015         foreach (qw(setup bill last_bill)) {
2016           $new->set($_, $old->get($_));
2017         }
2018       }
2019       $error = $new->insert( allow_pkgpart => $same_pkgpart );
2020       # transfer services
2021       if ( $old ) {
2022         $error ||= $old->transfer($new);
2023       }
2024       if ( $error and $error > 0 ) {
2025         # no reason why this should ever fail, but still...
2026         $error = "Unable to transfer all services from supplemental package ".
2027           $old->pkgnum;
2028       }
2029       if ( $error ) {
2030         $dbh->rollback if $oldAutoCommit;
2031         return $error;
2032       }
2033       push @new_supp_pkgs, $new;
2034     }
2035   } # if !$opt->{'cust_pkg'}
2036     # because if there is one, then supplemental packages would already
2037     # have been created for it.
2038
2039   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
2040   #remaining time.
2041   #Don't allow billing the package (preceding period packages and/or 
2042   #outstanding usage) if we are keeping dates (i.e. location changing), 
2043   #because the new package will be billed for the same date range.
2044   #Supplemental packages are also canceled here.
2045
2046   # during scheduled changes, avoid canceling the package we just
2047   # changed to (duh)
2048   $self->set('change_to_pkgnum' => '');
2049
2050   $error = $self->cancel(
2051     quiet          => 1, 
2052     unused_credit  => $unused_credit,
2053     nobill         => $keep_dates,
2054     change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
2055   );
2056   if ($error) {
2057     $dbh->rollback if $oldAutoCommit;
2058     return $error;
2059   }
2060
2061   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
2062     #$self->cust_main
2063     my $error = $cust_pkg->cust_main->bill( 
2064       'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
2065     );
2066     if ( $error ) {
2067       $dbh->rollback if $oldAutoCommit;
2068       return $error;
2069     }
2070   }
2071
2072   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2073
2074   $cust_pkg;
2075
2076 }
2077
2078 =item change_later OPTION => VALUE...
2079
2080 Schedule a package change for a later date.  This actually orders the new
2081 package immediately, but sets its start date for a future date, and sets
2082 the current package to expire on the same date.
2083
2084 If the package is already scheduled for a change, this can be called with 
2085 'start_date' to change the scheduled date, or with pkgpart and/or 
2086 locationnum to modify the package change.  To cancel the scheduled change 
2087 entirely, see C<abort_change>.
2088
2089 Options include:
2090
2091 =over 4
2092
2093 =item start_date
2094
2095 The date for the package change.  Required, and must be in the future.
2096
2097 =item pkgpart
2098
2099 =item locationnum
2100
2101 =item quantity
2102
2103 The pkgpart. locationnum, and quantity of the new package, with the same 
2104 meaning as in C<change>.
2105
2106 =back
2107
2108 =cut
2109
2110 sub change_later {
2111   my $self = shift;
2112   my $opt = ref($_[0]) ? shift : { @_ };
2113
2114   my $oldAutoCommit = $FS::UID::AutoCommit;
2115   local $FS::UID::AutoCommit = 0;
2116   my $dbh = dbh;
2117
2118   my $cust_main = $self->cust_main;
2119
2120   my $date = delete $opt->{'start_date'} or return 'start_date required';
2121  
2122   if ( $date <= time ) {
2123     $dbh->rollback if $oldAutoCommit;
2124     return "start_date $date is in the past";
2125   }
2126
2127   my $error;
2128
2129   if ( $self->change_to_pkgnum ) {
2130     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
2131     my $new_pkgpart = $opt->{'pkgpart'}
2132         if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart;
2133     my $new_locationnum = $opt->{'locationnum'}
2134         if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum;
2135     my $new_quantity = $opt->{'quantity'}
2136         if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity;
2137     if ( $new_pkgpart or $new_locationnum or $new_quantity ) {
2138       # it hasn't been billed yet, so in principle we could just edit
2139       # it in place (w/o a package change), but that's bad form.
2140       # So change the package according to the new options...
2141       my $err_or_pkg = $change_to->change(%$opt);
2142       if ( ref $err_or_pkg ) {
2143         # Then set that package up for a future start.
2144         $self->set('change_to_pkgnum', $err_or_pkg->pkgnum);
2145         $self->set('expire', $date); # in case it's different
2146         $err_or_pkg->set('start_date', $date);
2147         $err_or_pkg->set('change_date', '');
2148         $err_or_pkg->set('change_pkgnum', '');
2149
2150         $error = $self->replace       ||
2151                  $err_or_pkg->replace ||
2152                  $change_to->cancel   ||
2153                  $change_to->delete;
2154       } else {
2155         $error = $err_or_pkg;
2156       }
2157     } else { # change the start date only.
2158       $self->set('expire', $date);
2159       $change_to->set('start_date', $date);
2160       $error = $self->replace || $change_to->replace;
2161     }
2162     if ( $error ) {
2163       $dbh->rollback if $oldAutoCommit;
2164       return $error;
2165     } else {
2166       $dbh->commit if $oldAutoCommit;
2167       return '';
2168     }
2169   } # if $self->change_to_pkgnum
2170
2171   my $new_pkgpart = $opt->{'pkgpart'}
2172       if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart;
2173   my $new_locationnum = $opt->{'locationnum'}
2174       if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum;
2175   my $new_quantity = $opt->{'quantity'}
2176       if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity;
2177
2178   return '' unless $new_pkgpart or $new_locationnum or $new_quantity; # wouldn't do anything
2179
2180   my %hash = (
2181     'custnum'     => $self->custnum,
2182     'pkgpart'     => ($opt->{'pkgpart'}     || $self->pkgpart),
2183     'locationnum' => ($opt->{'locationnum'} || $self->locationnum),
2184     'quantity'    => ($opt->{'quantity'}    || $self->quantity),
2185     'start_date'  => $date,
2186   );
2187   my $new = FS::cust_pkg->new(\%hash);
2188   $error = $new->insert('change' => 1, 
2189                         'allow_pkgpart' => ($new_pkgpart ? 0 : 1));
2190   if ( !$error ) {
2191     $self->set('change_to_pkgnum', $new->pkgnum);
2192     $self->set('expire', $date);
2193     $error = $self->replace;
2194   }
2195   if ( $error ) {
2196     $dbh->rollback if $oldAutoCommit;
2197   } else {
2198     $dbh->commit if $oldAutoCommit;
2199   }
2200
2201   $error;
2202 }
2203
2204 =item abort_change
2205
2206 Cancels a future package change scheduled by C<change_later>.
2207
2208 =cut
2209
2210 sub abort_change {
2211   my $self = shift;
2212   my $pkgnum = $self->change_to_pkgnum;
2213   my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum;
2214   my $error;
2215   if ( $change_to ) {
2216     $error = $change_to->cancel || $change_to->delete;
2217     return $error if $error;
2218   }
2219   $self->set('change_to_pkgnum', '');
2220   $self->set('expire', '');
2221   $self->replace;
2222 }
2223
2224 =item set_quantity QUANTITY
2225
2226 Change the package's quantity field.  This is the one package property
2227 that can safely be changed without canceling and reordering the package
2228 (because it doesn't affect tax eligibility).  Returns an error or an 
2229 empty string.
2230
2231 =cut
2232
2233 sub set_quantity {
2234   my $self = shift;
2235   $self = $self->replace_old; # just to make sure
2236   my $qty = shift;
2237   ($qty =~ /^\d+$/ and $qty > 0) or return "bad package quantity $qty";
2238   $self->set('quantity' => $qty);
2239   $self->replace;
2240 }
2241
2242 use Storable 'thaw';
2243 use MIME::Base64;
2244 use Data::Dumper;
2245 sub process_bulk_cust_pkg {
2246   my $job = shift;
2247   my $param = thaw(decode_base64(shift));
2248   warn Dumper($param) if $DEBUG;
2249
2250   my $old_part_pkg = qsearchs('part_pkg', 
2251                               { pkgpart => $param->{'old_pkgpart'} });
2252   my $new_part_pkg = qsearchs('part_pkg',
2253                               { pkgpart => $param->{'new_pkgpart'} });
2254   die "Must select a new package type\n" unless $new_part_pkg;
2255   #my $keep_dates = $param->{'keep_dates'} || 0;
2256   my $keep_dates = 1; # there is no good reason to turn this off
2257
2258   local $SIG{HUP} = 'IGNORE';
2259   local $SIG{INT} = 'IGNORE';
2260   local $SIG{QUIT} = 'IGNORE';
2261   local $SIG{TERM} = 'IGNORE';
2262   local $SIG{TSTP} = 'IGNORE';
2263   local $SIG{PIPE} = 'IGNORE';
2264
2265   my $oldAutoCommit = $FS::UID::AutoCommit;
2266   local $FS::UID::AutoCommit = 0;
2267   my $dbh = dbh;
2268
2269   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
2270
2271   my $i = 0;
2272   foreach my $old_cust_pkg ( @cust_pkgs ) {
2273     $i++;
2274     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
2275     if ( $old_cust_pkg->getfield('cancel') ) {
2276       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
2277         $old_cust_pkg->pkgnum."\n"
2278         if $DEBUG;
2279       next;
2280     }
2281     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
2282       if $DEBUG;
2283     my $error = $old_cust_pkg->change(
2284       'pkgpart'     => $param->{'new_pkgpart'},
2285       'keep_dates'  => $keep_dates
2286     );
2287     if ( !ref($error) ) { # change returns the cust_pkg on success
2288       $dbh->rollback;
2289       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
2290     }
2291   }
2292   $dbh->commit if $oldAutoCommit;
2293   return;
2294 }
2295
2296 =item last_bill
2297
2298 Returns the last bill date, or if there is no last bill date, the setup date.
2299 Useful for billing metered services.
2300
2301 =cut
2302
2303 sub last_bill {
2304   my $self = shift;
2305   return $self->setfield('last_bill', $_[0]) if @_;
2306   return $self->getfield('last_bill') if $self->getfield('last_bill');
2307   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
2308                                                   'edate'  => $self->bill,  } );
2309   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
2310 }
2311
2312 =item last_cust_pkg_reason ACTION
2313
2314 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
2315 Returns false if there is no reason or the package is not currenly ACTION'd
2316 ACTION is one of adjourn, susp, cancel, or expire.
2317
2318 =cut
2319
2320 sub last_cust_pkg_reason {
2321   my ( $self, $action ) = ( shift, shift );
2322   my $date = $self->get($action);
2323   qsearchs( {
2324               'table' => 'cust_pkg_reason',
2325               'hashref' => { 'pkgnum' => $self->pkgnum,
2326                              'action' => substr(uc($action), 0, 1),
2327                              'date'   => $date,
2328                            },
2329               'order_by' => 'ORDER BY num DESC LIMIT 1',
2330            } );
2331 }
2332
2333 =item last_reason ACTION
2334
2335 Returns the most recent ACTION FS::reason associated with the package.
2336 Returns false if there is no reason or the package is not currenly ACTION'd
2337 ACTION is one of adjourn, susp, cancel, or expire.
2338
2339 =cut
2340
2341 sub last_reason {
2342   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
2343   $cust_pkg_reason->reason
2344     if $cust_pkg_reason;
2345 }
2346
2347 =item part_pkg
2348
2349 Returns the definition for this billing item, as an FS::part_pkg object (see
2350 L<FS::part_pkg>).
2351
2352 =cut
2353
2354 sub part_pkg {
2355   my $self = shift;
2356   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
2357   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
2358   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
2359 }
2360
2361 =item old_cust_pkg
2362
2363 Returns the cancelled package this package was changed from, if any.
2364
2365 =cut
2366
2367 sub old_cust_pkg {
2368   my $self = shift;
2369   return '' unless $self->change_pkgnum;
2370   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
2371 }
2372
2373 =item change_cust_main
2374
2375 Returns the customter this package was detached to, if any.
2376
2377 =cut
2378
2379 sub change_cust_main {
2380   my $self = shift;
2381   return '' unless $self->change_custnum;
2382   qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
2383 }
2384
2385 =item calc_setup
2386
2387 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
2388 item.
2389
2390 =cut
2391
2392 sub calc_setup {
2393   my $self = shift;
2394   $self->part_pkg->calc_setup($self, @_);
2395 }
2396
2397 =item calc_recur
2398
2399 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
2400 item.
2401
2402 =cut
2403
2404 sub calc_recur {
2405   my $self = shift;
2406   $self->part_pkg->calc_recur($self, @_);
2407 }
2408
2409 =item base_setup
2410
2411 Calls the I<base_setup> of the FS::part_pkg object associated with this billing
2412 item.
2413
2414 =cut
2415
2416 sub base_setup {
2417   my $self = shift;
2418   $self->part_pkg->base_setup($self, @_);
2419 }
2420
2421 =item base_recur
2422
2423 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
2424 item.
2425
2426 =cut
2427
2428 sub base_recur {
2429   my $self = shift;
2430   $self->part_pkg->base_recur($self, @_);
2431 }
2432
2433 =item calc_remain
2434
2435 Calls the I<calc_remain> of the FS::part_pkg object associated with this
2436 billing item.
2437
2438 =cut
2439
2440 sub calc_remain {
2441   my $self = shift;
2442   $self->part_pkg->calc_remain($self, @_);
2443 }
2444
2445 =item calc_cancel
2446
2447 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
2448 billing item.
2449
2450 =cut
2451
2452 sub calc_cancel {
2453   my $self = shift;
2454   $self->part_pkg->calc_cancel($self, @_);
2455 }
2456
2457 =item cust_bill_pkg
2458
2459 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
2460
2461 =cut
2462
2463 sub cust_bill_pkg {
2464   my $self = shift;
2465   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
2466 }
2467
2468 =item cust_pkg_detail [ DETAILTYPE ]
2469
2470 Returns any customer package details for this package (see
2471 L<FS::cust_pkg_detail>).
2472
2473 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2474
2475 =cut
2476
2477 sub cust_pkg_detail {
2478   my $self = shift;
2479   my %hash = ( 'pkgnum' => $self->pkgnum );
2480   $hash{detailtype} = shift if @_;
2481   qsearch({
2482     'table'    => 'cust_pkg_detail',
2483     'hashref'  => \%hash,
2484     'order_by' => 'ORDER BY weight, pkgdetailnum',
2485   });
2486 }
2487
2488 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
2489
2490 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
2491
2492 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2493
2494 If there is an error, returns the error, otherwise returns false.
2495
2496 =cut
2497
2498 sub set_cust_pkg_detail {
2499   my( $self, $detailtype, @details ) = @_;
2500
2501   local $SIG{HUP} = 'IGNORE';
2502   local $SIG{INT} = 'IGNORE';
2503   local $SIG{QUIT} = 'IGNORE';
2504   local $SIG{TERM} = 'IGNORE';
2505   local $SIG{TSTP} = 'IGNORE';
2506   local $SIG{PIPE} = 'IGNORE';
2507
2508   my $oldAutoCommit = $FS::UID::AutoCommit;
2509   local $FS::UID::AutoCommit = 0;
2510   my $dbh = dbh;
2511
2512   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
2513     my $error = $current->delete;
2514     if ( $error ) {
2515       $dbh->rollback if $oldAutoCommit;
2516       return "error removing old detail: $error";
2517     }
2518   }
2519
2520   foreach my $detail ( @details ) {
2521     my $cust_pkg_detail = new FS::cust_pkg_detail {
2522       'pkgnum'     => $self->pkgnum,
2523       'detailtype' => $detailtype,
2524       'detail'     => $detail,
2525     };
2526     my $error = $cust_pkg_detail->insert;
2527     if ( $error ) {
2528       $dbh->rollback if $oldAutoCommit;
2529       return "error adding new detail: $error";
2530     }
2531
2532   }
2533
2534   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2535   '';
2536
2537 }
2538
2539 =item cust_event
2540
2541 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
2542
2543 =cut
2544
2545 #false laziness w/cust_bill.pm
2546 sub cust_event {
2547   my $self = shift;
2548   qsearch({
2549     'table'     => 'cust_event',
2550     'addl_from' => 'JOIN part_event USING ( eventpart )',
2551     'hashref'   => { 'tablenum' => $self->pkgnum },
2552     'extra_sql' => " AND eventtable = 'cust_pkg' ",
2553   });
2554 }
2555
2556 =item num_cust_event
2557
2558 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
2559
2560 =cut
2561
2562 #false laziness w/cust_bill.pm
2563 sub num_cust_event {
2564   my $self = shift;
2565   my $sql =
2566     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
2567     "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
2568   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
2569   $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
2570   $sth->fetchrow_arrayref->[0];
2571 }
2572
2573 =item part_pkg_currency_option OPTIONNAME
2574
2575 Returns a two item list consisting of the currency of this customer, if any,
2576 and a value for the provided option.  If the customer has a currency, the value
2577 is the option value the given name and the currency (see
2578 L<FS::part_pkg_currency>).  Otherwise, if the customer has no currency, is the
2579 regular option value for the given name (see L<FS::part_pkg_option>).
2580
2581 =cut
2582
2583 sub part_pkg_currency_option {
2584   my( $self, $optionname ) = @_;
2585   my $part_pkg = $self->part_pkg;
2586   if ( my $currency = $self->cust_main->currency ) {
2587     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
2588   } else {
2589     ('', $part_pkg->option($optionname) );
2590   }
2591 }
2592
2593 =item cust_svc [ SVCPART ] (old, deprecated usage)
2594
2595 =item cust_svc [ OPTION => VALUE ... ] (current usage)
2596
2597 Returns the services for this package, as FS::cust_svc objects (see
2598 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
2599 spcififed, returns only the matching services.
2600
2601 =cut
2602
2603 sub cust_svc {
2604   my $self = shift;
2605
2606   return () unless $self->num_cust_svc(@_);
2607
2608   my %opt = ();
2609   if ( @_ && $_[0] =~ /^\d+/ ) {
2610     $opt{svcpart} = shift;
2611   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2612     %opt = %{ $_[0] };
2613   } elsif ( @_ ) {
2614     %opt = @_;
2615   }
2616
2617   my %search = (
2618     'table'   => 'cust_svc',
2619     'hashref' => { 'pkgnum' => $self->pkgnum },
2620   );
2621   if ( $opt{svcpart} ) {
2622     $search{hashref}->{svcpart} = $opt{'svcpart'};
2623   }
2624   if ( $opt{'svcdb'} ) {
2625     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
2626     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
2627   }
2628
2629   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
2630
2631   #if ( $self->{'_svcnum'} ) {
2632   #  values %{ $self->{'_svcnum'}->cache };
2633   #} else {
2634     $self->_sort_cust_svc( [ qsearch(\%search) ] );
2635   #}
2636
2637 }
2638
2639 =item overlimit [ SVCPART ]
2640
2641 Returns the services for this package which have exceeded their
2642 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
2643 is specified, return only the matching services.
2644
2645 =cut
2646
2647 sub overlimit {
2648   my $self = shift;
2649   return () unless $self->num_cust_svc(@_);
2650   grep { $_->overlimit } $self->cust_svc(@_);
2651 }
2652
2653 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2654
2655 Returns historical services for this package created before END TIMESTAMP and
2656 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
2657 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
2658 I<pkg_svc.hidden> flag will be omitted.
2659
2660 =cut
2661
2662 sub h_cust_svc {
2663   my $self = shift;
2664   warn "$me _h_cust_svc called on $self\n"
2665     if $DEBUG;
2666
2667   my ($end, $start, $mode) = @_;
2668   my @cust_svc = $self->_sort_cust_svc(
2669     [ qsearch( 'h_cust_svc',
2670       { 'pkgnum' => $self->pkgnum, },  
2671       FS::h_cust_svc->sql_h_search(@_),  
2672     ) ]
2673   );
2674   if ( defined($mode) && $mode eq 'I' ) {
2675     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
2676     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
2677   } else {
2678     return @cust_svc;
2679   }
2680 }
2681
2682 sub _sort_cust_svc {
2683   my( $self, $arrayref ) = @_;
2684
2685   my $sort =
2686     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
2687
2688   my %pkg_svc = map { $_->svcpart => $_ }
2689                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
2690
2691   map  { $_->[0] }
2692   sort $sort
2693   map {
2694         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
2695         [ $_,
2696           $pkg_svc ? $pkg_svc->primary_svc : '',
2697           $pkg_svc ? $pkg_svc->quantity : 0,
2698         ];
2699       }
2700   @$arrayref;
2701
2702 }
2703
2704 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
2705
2706 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
2707
2708 Returns the number of services for this package.  Available options are svcpart
2709 and svcdb.  If either is spcififed, returns only the matching services.
2710
2711 =cut
2712
2713 sub num_cust_svc {
2714   my $self = shift;
2715
2716   return $self->{'_num_cust_svc'}
2717     if !scalar(@_)
2718        && exists($self->{'_num_cust_svc'})
2719        && $self->{'_num_cust_svc'} =~ /\d/;
2720
2721   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
2722     if $DEBUG > 2;
2723
2724   my %opt = ();
2725   if ( @_ && $_[0] =~ /^\d+/ ) {
2726     $opt{svcpart} = shift;
2727   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2728     %opt = %{ $_[0] };
2729   } elsif ( @_ ) {
2730     %opt = @_;
2731   }
2732
2733   my $select = 'SELECT COUNT(*) FROM cust_svc ';
2734   my $where = ' WHERE pkgnum = ? ';
2735   my @param = ($self->pkgnum);
2736
2737   if ( $opt{'svcpart'} ) {
2738     $where .= ' AND svcpart = ? ';
2739     push @param, $opt{'svcpart'};
2740   }
2741   if ( $opt{'svcdb'} ) {
2742     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
2743     $where .= ' AND svcdb = ? ';
2744     push @param, $opt{'svcdb'};
2745   }
2746
2747   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
2748   $sth->execute(@param) or die $sth->errstr;
2749   $sth->fetchrow_arrayref->[0];
2750 }
2751
2752 =item available_part_svc 
2753
2754 Returns a list of FS::part_svc objects representing services included in this
2755 package but not yet provisioned.  Each FS::part_svc object also has an extra
2756 field, I<num_avail>, which specifies the number of available services.
2757
2758 =cut
2759
2760 sub available_part_svc {
2761   my $self = shift;
2762
2763   my $pkg_quantity = $self->quantity || 1;
2764
2765   grep { $_->num_avail > 0 }
2766     map {
2767           my $part_svc = $_->part_svc;
2768           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
2769             $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
2770
2771           # more evil encapsulation breakage
2772           if($part_svc->{'Hash'}{'num_avail'} > 0) {
2773             my @exports = $part_svc->part_export_did;
2774             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
2775           }
2776
2777           $part_svc;
2778         }
2779       $self->part_pkg->pkg_svc;
2780 }
2781
2782 =item part_svc [ OPTION => VALUE ... ]
2783
2784 Returns a list of FS::part_svc objects representing provisioned and available
2785 services included in this package.  Each FS::part_svc object also has the
2786 following extra fields:
2787
2788 =over 4
2789
2790 =item num_cust_svc  (count)
2791
2792 =item num_avail     (quantity - count)
2793
2794 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
2795
2796 =back
2797
2798 Accepts one option: summarize_size.  If specified and non-zero, will omit the
2799 extra cust_pkg_svc option for objects where num_cust_svc is this size or
2800 greater.
2801
2802 =cut
2803
2804 #svcnum
2805 #label -> ($cust_svc->label)[1]
2806
2807 sub part_svc {
2808   my $self = shift;
2809   my %opt = @_;
2810
2811   my $pkg_quantity = $self->quantity || 1;
2812
2813   #XXX some sort of sort order besides numeric by svcpart...
2814   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
2815     my $pkg_svc = $_;
2816     my $part_svc = $pkg_svc->part_svc;
2817     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2818     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
2819     $part_svc->{'Hash'}{'num_avail'}    =
2820       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
2821     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2822         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
2823       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
2824           && $num_cust_svc >= $opt{summarize_size};
2825     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
2826     $part_svc;
2827   } $self->part_pkg->pkg_svc;
2828
2829   #extras
2830   push @part_svc, map {
2831     my $part_svc = $_;
2832     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2833     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
2834     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
2835     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2836       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
2837     $part_svc;
2838   } $self->extra_part_svc;
2839
2840   @part_svc;
2841
2842 }
2843
2844 =item extra_part_svc
2845
2846 Returns a list of FS::part_svc objects corresponding to services in this
2847 package which are still provisioned but not (any longer) available in the
2848 package definition.
2849
2850 =cut
2851
2852 sub extra_part_svc {
2853   my $self = shift;
2854
2855   my $pkgnum  = $self->pkgnum;
2856   #my $pkgpart = $self->pkgpart;
2857
2858 #  qsearch( {
2859 #    'table'     => 'part_svc',
2860 #    'hashref'   => {},
2861 #    'extra_sql' =>
2862 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
2863 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
2864 #                       AND pkg_svc.pkgpart = ?
2865 #                       AND quantity > 0 
2866 #                 )
2867 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
2868 #                       LEFT JOIN cust_pkg USING ( pkgnum )
2869 #                     WHERE cust_svc.svcpart = part_svc.svcpart
2870 #                       AND pkgnum = ?
2871 #                 )",
2872 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
2873 #  } );
2874
2875 #seems to benchmark slightly faster... (or did?)
2876
2877   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
2878   my $pkgparts = join(',', @pkgparts);
2879
2880   qsearch( {
2881     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
2882     #MySQL doesn't grok DISINCT ON
2883     'select'      => 'DISTINCT part_svc.*',
2884     'table'       => 'part_svc',
2885     'addl_from'   =>
2886       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
2887                                AND pkg_svc.pkgpart IN ($pkgparts)
2888                                AND quantity > 0
2889                              )
2890        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
2891        LEFT JOIN cust_pkg USING ( pkgnum )
2892       ",
2893     'hashref'     => {},
2894     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
2895     'extra_param' => [ [$self->pkgnum=>'int'] ],
2896   } );
2897 }
2898
2899 =item status
2900
2901 Returns a short status string for this package, currently:
2902
2903 =over 4
2904
2905 =item not yet billed
2906
2907 =item one-time charge
2908
2909 =item active
2910
2911 =item suspended
2912
2913 =item cancelled
2914
2915 =back
2916
2917 =cut
2918
2919 sub status {
2920   my $self = shift;
2921
2922   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2923
2924   return 'cancelled' if $self->get('cancel');
2925   return 'suspended' if $self->susp;
2926   return 'not yet billed' unless $self->setup;
2927   return 'one-time charge' if $freq =~ /^(0|$)/;
2928   return 'active';
2929 }
2930
2931 =item ucfirst_status
2932
2933 Returns the status with the first character capitalized.
2934
2935 =cut
2936
2937 sub ucfirst_status {
2938   ucfirst(shift->status);
2939 }
2940
2941 =item statuses
2942
2943 Class method that returns the list of possible status strings for packages
2944 (see L<the status method|/status>).  For example:
2945
2946   @statuses = FS::cust_pkg->statuses();
2947
2948 =cut
2949
2950 tie my %statuscolor, 'Tie::IxHash', 
2951   'not yet billed'  => '009999', #teal? cyan?
2952   'one-time charge' => '000000',
2953   'active'          => '00CC00',
2954   'suspended'       => 'FF9900',
2955   'cancelled'       => 'FF0000',
2956 ;
2957
2958 sub statuses {
2959   my $self = shift; #could be class...
2960   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2961   #                                    # mayble split btw one-time vs. recur
2962     keys %statuscolor;
2963 }
2964
2965 =item statuscolor
2966
2967 Returns a hex triplet color string for this package's status.
2968
2969 =cut
2970
2971 sub statuscolor {
2972   my $self = shift;
2973   $statuscolor{$self->status};
2974 }
2975
2976 =item pkg_label
2977
2978 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
2979 "pkg - comment" depending on user preference).
2980
2981 =cut
2982
2983 sub pkg_label {
2984   my $self = shift;
2985   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
2986   $label = $self->pkgnum. ": $label"
2987     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
2988   $label;
2989 }
2990
2991 =item pkg_label_long
2992
2993 Returns a long label for this package, adding the primary service's label to
2994 pkg_label.
2995
2996 =cut
2997
2998 sub pkg_label_long {
2999   my $self = shift;
3000   my $label = $self->pkg_label;
3001   my $cust_svc = $self->primary_cust_svc;
3002   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3003   $label;
3004 }
3005
3006 =item pkg_locale
3007
3008 Returns a customer-localized label for this package.
3009
3010 =cut
3011
3012 sub pkg_locale {
3013   my $self = shift;
3014   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3015 }
3016
3017 =item primary_cust_svc
3018
3019 Returns a primary service (as FS::cust_svc object) if one can be identified.
3020
3021 =cut
3022
3023 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3024
3025 sub primary_cust_svc {
3026   my $self = shift;
3027
3028   my @cust_svc = $self->cust_svc;
3029
3030   return '' unless @cust_svc; #no serivces - irrelevant then
3031   
3032   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3033
3034   # primary service as specified in the package definition
3035   # or exactly one service definition with quantity one
3036   my $svcpart = $self->part_pkg->svcpart;
3037   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3038   return $cust_svc[0] if scalar(@cust_svc) == 1;
3039
3040   #couldn't identify one thing..
3041   return '';
3042 }
3043
3044 =item labels
3045
3046 Returns a list of lists, calling the label method for all services
3047 (see L<FS::cust_svc>) of this billing item.
3048
3049 =cut
3050
3051 sub labels {
3052   my $self = shift;
3053   map { [ $_->label ] } $self->cust_svc;
3054 }
3055
3056 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3057
3058 Like the labels method, but returns historical information on services that
3059 were active as of END_TIMESTAMP and (optionally) not cancelled before
3060 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3061 I<pkg_svc.hidden> flag will be omitted.
3062
3063 Returns a list of lists, calling the label method for all (historical) services
3064 (see L<FS::h_cust_svc>) of this billing item.
3065
3066 =cut
3067
3068 sub h_labels {
3069   my $self = shift;
3070   warn "$me _h_labels called on $self\n"
3071     if $DEBUG;
3072   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3073 }
3074
3075 =item labels_short
3076
3077 Like labels, except returns a simple flat list, and shortens long
3078 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3079 identical services to one line that lists the service label and the number of
3080 individual services rather than individual items.
3081
3082 =cut
3083
3084 sub labels_short {
3085   shift->_labels_short( 'labels', @_ );
3086 }
3087
3088 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3089
3090 Like h_labels, except returns a simple flat list, and shortens long
3091 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3092 identical services to one line that lists the service label and the number of
3093 individual services rather than individual items.
3094
3095 =cut
3096
3097 sub h_labels_short {
3098   shift->_labels_short( 'h_labels', @_ );
3099 }
3100
3101 sub _labels_short {
3102   my( $self, $method ) = ( shift, shift );
3103
3104   warn "$me _labels_short called on $self with $method method\n"
3105     if $DEBUG;
3106
3107   my $conf = new FS::Conf;
3108   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3109
3110   warn "$me _labels_short populating \%labels\n"
3111     if $DEBUG;
3112
3113   my %labels;
3114   #tie %labels, 'Tie::IxHash';
3115   push @{ $labels{$_->[0]} }, $_->[1]
3116     foreach $self->$method(@_);
3117
3118   warn "$me _labels_short populating \@labels\n"
3119     if $DEBUG;
3120
3121   my @labels;
3122   foreach my $label ( keys %labels ) {
3123     my %seen = ();
3124     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3125     my $num = scalar(@values);
3126     warn "$me _labels_short $num items for $label\n"
3127       if $DEBUG;
3128
3129     if ( $num > $max_same_services ) {
3130       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3131         if $DEBUG;
3132       push @labels, "$label ($num)";
3133     } else {
3134       if ( $conf->exists('cust_bill-consolidate_services') ) {
3135         warn "$me _labels_short   consolidating services\n"
3136           if $DEBUG;
3137         # push @labels, "$label: ". join(', ', @values);
3138         while ( @values ) {
3139           my $detail = "$label: ";
3140           $detail .= shift(@values). ', '
3141             while @values
3142                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3143           $detail =~ s/, $//;
3144           push @labels, $detail;
3145         }
3146         warn "$me _labels_short   done consolidating services\n"
3147           if $DEBUG;
3148       } else {
3149         warn "$me _labels_short   adding service data\n"
3150           if $DEBUG;
3151         push @labels, map { "$label: $_" } @values;
3152       }
3153     }
3154   }
3155
3156  @labels;
3157
3158 }
3159
3160 =item cust_main
3161
3162 Returns the parent customer object (see L<FS::cust_main>).
3163
3164 =cut
3165
3166 sub cust_main {
3167   my $self = shift;
3168   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
3169 }
3170
3171 =item balance
3172
3173 Returns the balance for this specific package, when using
3174 experimental package balance.
3175
3176 =cut
3177
3178 sub balance {
3179   my $self = shift;
3180   $self->cust_main->balance_pkgnum( $self->pkgnum );
3181 }
3182
3183 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3184
3185 =item cust_location
3186
3187 Returns the location object, if any (see L<FS::cust_location>).
3188
3189 =item cust_location_or_main
3190
3191 If this package is associated with a location, returns the locaiton (see
3192 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3193
3194 =item location_label [ OPTION => VALUE ... ]
3195
3196 Returns the label of the location object (see L<FS::cust_location>).
3197
3198 =cut
3199
3200 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3201
3202 =item tax_locationnum
3203
3204 Returns the foreign key to a L<FS::cust_location> object for calculating  
3205 tax on this package, as determined by the C<tax-pkg_address> and 
3206 C<tax-ship_address> configuration flags.
3207
3208 =cut
3209
3210 sub tax_locationnum {
3211   my $self = shift;
3212   my $conf = FS::Conf->new;
3213   if ( $conf->exists('tax-pkg_address') ) {
3214     return $self->locationnum;
3215   }
3216   elsif ( $conf->exists('tax-ship_address') ) {
3217     return $self->cust_main->ship_locationnum;
3218   }
3219   else {
3220     return $self->cust_main->bill_locationnum;
3221   }
3222 }
3223
3224 =item tax_location
3225
3226 Returns the L<FS::cust_location> object for tax_locationnum.
3227
3228 =cut
3229
3230 sub tax_location {
3231   my $self = shift;
3232   FS::cust_location->by_key( $self->tax_locationnum )
3233 }
3234
3235 =item seconds_since TIMESTAMP
3236
3237 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3238 package have been online since TIMESTAMP, according to the session monitor.
3239
3240 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3241 L<Time::Local> and L<Date::Parse> for conversion functions.
3242
3243 =cut
3244
3245 sub seconds_since {
3246   my($self, $since) = @_;
3247   my $seconds = 0;
3248
3249   foreach my $cust_svc (
3250     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3251   ) {
3252     $seconds += $cust_svc->seconds_since($since);
3253   }
3254
3255   $seconds;
3256
3257 }
3258
3259 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3260
3261 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3262 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3263 (exclusive).
3264
3265 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3266 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3267 functions.
3268
3269
3270 =cut
3271
3272 sub seconds_since_sqlradacct {
3273   my($self, $start, $end) = @_;
3274
3275   my $seconds = 0;
3276
3277   foreach my $cust_svc (
3278     grep {
3279       my $part_svc = $_->part_svc;
3280       $part_svc->svcdb eq 'svc_acct'
3281         && scalar($part_svc->part_export_usage);
3282     } $self->cust_svc
3283   ) {
3284     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
3285   }
3286
3287   $seconds;
3288
3289 }
3290
3291 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
3292
3293 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
3294 in this package for sessions ending between TIMESTAMP_START (inclusive) and
3295 TIMESTAMP_END
3296 (exclusive).
3297
3298 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3299 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3300 functions.
3301
3302 =cut
3303
3304 sub attribute_since_sqlradacct {
3305   my($self, $start, $end, $attrib) = @_;
3306
3307   my $sum = 0;
3308
3309   foreach my $cust_svc (
3310     grep {
3311       my $part_svc = $_->part_svc;
3312       $part_svc->svcdb eq 'svc_acct'
3313         && scalar($part_svc->part_export_usage);
3314     } $self->cust_svc
3315   ) {
3316     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
3317   }
3318
3319   $sum;
3320
3321 }
3322
3323 =item quantity
3324
3325 =cut
3326
3327 sub quantity {
3328   my( $self, $value ) = @_;
3329   if ( defined($value) ) {
3330     $self->setfield('quantity', $value);
3331   }
3332   $self->getfield('quantity') || 1;
3333 }
3334
3335 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
3336
3337 Transfers as many services as possible from this package to another package.
3338
3339 The destination package can be specified by pkgnum by passing an FS::cust_pkg
3340 object.  The destination package must already exist.
3341
3342 Services are moved only if the destination allows services with the correct
3343 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
3344 this option with caution!  No provision is made for export differences
3345 between the old and new service definitions.  Probably only should be used
3346 when your exports for all service definitions of a given svcdb are identical.
3347 (attempt a transfer without it first, to move all possible svcpart-matching
3348 services)
3349
3350 Any services that can't be moved remain in the original package.
3351
3352 Returns an error, if there is one; otherwise, returns the number of services 
3353 that couldn't be moved.
3354
3355 =cut
3356
3357 sub transfer {
3358   my ($self, $dest_pkgnum, %opt) = @_;
3359
3360   my $remaining = 0;
3361   my $dest;
3362   my %target;
3363
3364   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
3365     $dest = $dest_pkgnum;
3366     $dest_pkgnum = $dest->pkgnum;
3367   } else {
3368     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
3369   }
3370
3371   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
3372
3373   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
3374     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
3375   }
3376
3377   foreach my $cust_svc ($dest->cust_svc) {
3378     $target{$cust_svc->svcpart}--;
3379   }
3380
3381   my %svcpart2svcparts = ();
3382   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3383     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
3384     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
3385       next if exists $svcpart2svcparts{$svcpart};
3386       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
3387       $svcpart2svcparts{$svcpart} = [
3388         map  { $_->[0] }
3389         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
3390         map {
3391               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
3392                                                    'svcpart' => $_          } );
3393               [ $_,
3394                 $pkg_svc ? $pkg_svc->primary_svc : '',
3395                 $pkg_svc ? $pkg_svc->quantity : 0,
3396               ];
3397             }
3398
3399         grep { $_ != $svcpart }
3400         map  { $_->svcpart }
3401         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
3402       ];
3403       warn "alternates for svcpart $svcpart: ".
3404            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
3405         if $DEBUG;
3406     }
3407   }
3408
3409   foreach my $cust_svc ($self->cust_svc) {
3410     if($target{$cust_svc->svcpart} > 0
3411        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
3412       $target{$cust_svc->svcpart}--;
3413       my $new = new FS::cust_svc { $cust_svc->hash };
3414       $new->pkgnum($dest_pkgnum);
3415       my $error = $new->replace($cust_svc);
3416       return $error if $error;
3417     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3418       if ( $DEBUG ) {
3419         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
3420         warn "alternates to consider: ".
3421              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
3422       }
3423       my @alternate = grep {
3424                              warn "considering alternate svcpart $_: ".
3425                                   "$target{$_} available in new package\n"
3426                                if $DEBUG;
3427                              $target{$_} > 0;
3428                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
3429       if ( @alternate ) {
3430         warn "alternate(s) found\n" if $DEBUG;
3431         my $change_svcpart = $alternate[0];
3432         $target{$change_svcpart}--;
3433         my $new = new FS::cust_svc { $cust_svc->hash };
3434         $new->svcpart($change_svcpart);
3435         $new->pkgnum($dest_pkgnum);
3436         my $error = $new->replace($cust_svc);
3437         return $error if $error;
3438       } else {
3439         $remaining++;
3440       }
3441     } else {
3442       $remaining++
3443     }
3444   }
3445   return $remaining;
3446 }
3447
3448 =item grab_svcnums SVCNUM, SVCNUM ...
3449
3450 Change the pkgnum for the provided services to this packages.  If there is an
3451 error, returns the error, otherwise returns false.
3452
3453 =cut
3454
3455 sub grab_svcnums {
3456   my $self = shift;
3457   my @svcnum = @_;
3458
3459   local $SIG{HUP} = 'IGNORE';
3460   local $SIG{INT} = 'IGNORE';
3461   local $SIG{QUIT} = 'IGNORE';
3462   local $SIG{TERM} = 'IGNORE';
3463   local $SIG{TSTP} = 'IGNORE';
3464   local $SIG{PIPE} = 'IGNORE';
3465
3466   my $oldAutoCommit = $FS::UID::AutoCommit;
3467   local $FS::UID::AutoCommit = 0;
3468   my $dbh = dbh;
3469
3470   foreach my $svcnum (@svcnum) {
3471     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
3472       $dbh->rollback if $oldAutoCommit;
3473       return "unknown svcnum $svcnum";
3474     };
3475     $cust_svc->pkgnum( $self->pkgnum );
3476     my $error = $cust_svc->replace;
3477     if ( $error ) {
3478       $dbh->rollback if $oldAutoCommit;
3479       return $error;
3480     }
3481   }
3482
3483   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3484   '';
3485
3486 }
3487
3488 =item reexport
3489
3490 This method is deprecated.  See the I<depend_jobnum> option to the insert and
3491 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
3492
3493 =cut
3494
3495 #looks like this is still used by the order_pkg and change_pkg methods in
3496 # ClientAPI/MyAccount, need to look into those before removing
3497 sub reexport {
3498   my $self = shift;
3499
3500   local $SIG{HUP} = 'IGNORE';
3501   local $SIG{INT} = 'IGNORE';
3502   local $SIG{QUIT} = 'IGNORE';
3503   local $SIG{TERM} = 'IGNORE';
3504   local $SIG{TSTP} = 'IGNORE';
3505   local $SIG{PIPE} = 'IGNORE';
3506
3507   my $oldAutoCommit = $FS::UID::AutoCommit;
3508   local $FS::UID::AutoCommit = 0;
3509   my $dbh = dbh;
3510
3511   foreach my $cust_svc ( $self->cust_svc ) {
3512     #false laziness w/svc_Common::insert
3513     my $svc_x = $cust_svc->svc_x;
3514     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
3515       my $error = $part_export->export_insert($svc_x);
3516       if ( $error ) {
3517         $dbh->rollback if $oldAutoCommit;
3518         return $error;
3519       }
3520     }
3521   }
3522
3523   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3524   '';
3525
3526 }
3527
3528 =item export_pkg_change OLD_CUST_PKG
3529
3530 Calls the "pkg_change" export action for all services attached to this package.
3531
3532 =cut
3533
3534 sub export_pkg_change {
3535   my( $self, $old )  = ( shift, shift );
3536
3537   local $SIG{HUP} = 'IGNORE';
3538   local $SIG{INT} = 'IGNORE';
3539   local $SIG{QUIT} = 'IGNORE';
3540   local $SIG{TERM} = 'IGNORE';
3541   local $SIG{TSTP} = 'IGNORE';
3542   local $SIG{PIPE} = 'IGNORE';
3543
3544   my $oldAutoCommit = $FS::UID::AutoCommit;
3545   local $FS::UID::AutoCommit = 0;
3546   my $dbh = dbh;
3547
3548   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
3549     my $error = $svc_x->export('pkg_change', $self, $old);
3550     if ( $error ) {
3551       $dbh->rollback if $oldAutoCommit;
3552       return $error;
3553     }
3554   }
3555
3556   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3557   '';
3558
3559 }
3560
3561 =item insert_reason
3562
3563 Associates this package with a (suspension or cancellation) reason (see
3564 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
3565 L<FS::reason>).
3566
3567 Available options are:
3568
3569 =over 4
3570
3571 =item reason
3572
3573 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.
3574
3575 =item reason_otaker
3576
3577 the access_user (see L<FS::access_user>) providing the reason
3578
3579 =item date
3580
3581 a unix timestamp 
3582
3583 =item action
3584
3585 the action (cancel, susp, adjourn, expire) associated with the reason
3586
3587 =back
3588
3589 If there is an error, returns the error, otherwise returns false.
3590
3591 =cut
3592
3593 sub insert_reason {
3594   my ($self, %options) = @_;
3595
3596   my $otaker = $options{reason_otaker} ||
3597                $FS::CurrentUser::CurrentUser->username;
3598
3599   my $reasonnum;
3600   if ( $options{'reason'} =~ /^(\d+)$/ ) {
3601
3602     $reasonnum = $1;
3603
3604   } elsif ( ref($options{'reason'}) ) {
3605   
3606     return 'Enter a new reason (or select an existing one)'
3607       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
3608
3609     my $reason = new FS::reason({
3610       'reason_type' => $options{'reason'}->{'typenum'},
3611       'reason'      => $options{'reason'}->{'reason'},
3612     });
3613     my $error = $reason->insert;
3614     return $error if $error;
3615
3616     $reasonnum = $reason->reasonnum;
3617
3618   } else {
3619     return "Unparsable reason: ". $options{'reason'};
3620   }
3621
3622   my $cust_pkg_reason =
3623     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
3624                               'reasonnum' => $reasonnum, 
3625                               'otaker'    => $otaker,
3626                               'action'    => substr(uc($options{'action'}),0,1),
3627                               'date'      => $options{'date'}
3628                                                ? $options{'date'}
3629                                                : time,
3630                             });
3631
3632   $cust_pkg_reason->insert;
3633 }
3634
3635 =item insert_discount
3636
3637 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
3638 inserting a new discount on the fly (see L<FS::discount>).
3639
3640 Available options are:
3641
3642 =over 4
3643
3644 =item discountnum
3645
3646 =back
3647
3648 If there is an error, returns the error, otherwise returns false.
3649
3650 =cut
3651
3652 sub insert_discount {
3653   #my ($self, %options) = @_;
3654   my $self = shift;
3655
3656   my $cust_pkg_discount = new FS::cust_pkg_discount {
3657     'pkgnum'      => $self->pkgnum,
3658     'discountnum' => $self->discountnum,
3659     'months_used' => 0,
3660     'end_date'    => '', #XXX
3661     #for the create a new discount case
3662     '_type'       => $self->discountnum__type,
3663     'amount'      => $self->discountnum_amount,
3664     'percent'     => $self->discountnum_percent,
3665     'months'      => $self->discountnum_months,
3666     'setup'      => $self->discountnum_setup,
3667     #'disabled'    => $self->discountnum_disabled,
3668   };
3669
3670   $cust_pkg_discount->insert;
3671 }
3672
3673 =item set_usage USAGE_VALUE_HASHREF 
3674
3675 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3676 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3677 upbytes, downbytes, and totalbytes are appropriate keys.
3678
3679 All svc_accts which are part of this package have their values reset.
3680
3681 =cut
3682
3683 sub set_usage {
3684   my ($self, $valueref, %opt) = @_;
3685
3686   #only svc_acct can set_usage for now
3687   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3688     my $svc_x = $cust_svc->svc_x;
3689     $svc_x->set_usage($valueref, %opt)
3690       if $svc_x->can("set_usage");
3691   }
3692 }
3693
3694 =item recharge USAGE_VALUE_HASHREF 
3695
3696 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3697 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3698 upbytes, downbytes, and totalbytes are appropriate keys.
3699
3700 All svc_accts which are part of this package have their values incremented.
3701
3702 =cut
3703
3704 sub recharge {
3705   my ($self, $valueref) = @_;
3706
3707   #only svc_acct can set_usage for now
3708   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3709     my $svc_x = $cust_svc->svc_x;
3710     $svc_x->recharge($valueref)
3711       if $svc_x->can("recharge");
3712   }
3713 }
3714
3715 =item cust_pkg_discount
3716
3717 =cut
3718
3719 sub cust_pkg_discount {
3720   my $self = shift;
3721   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
3722 }
3723
3724 =item cust_pkg_discount_active
3725
3726 =cut
3727
3728 sub cust_pkg_discount_active {
3729   my $self = shift;
3730   grep { $_->status eq 'active' } $self->cust_pkg_discount;
3731 }
3732
3733 =item cust_pkg_usage
3734
3735 Returns a list of all voice usage counters attached to this package.
3736
3737 =cut
3738
3739 sub cust_pkg_usage {
3740   my $self = shift;
3741   qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
3742 }
3743
3744 =item apply_usage OPTIONS
3745
3746 Takes the following options:
3747 - cdr: a call detail record (L<FS::cdr>)
3748 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
3749 - minutes: the maximum number of minutes to be charged
3750
3751 Finds available usage minutes for a call of this class, and subtracts
3752 up to that many minutes from the usage pool.  If the usage pool is empty,
3753 and the C<cdr-minutes_priority> global config option is set, minutes may
3754 be taken from other calls as well.  Either way, an allocation record will
3755 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
3756 number of minutes of usage applied to the call.
3757
3758 =cut
3759
3760 sub apply_usage {
3761   my ($self, %opt) = @_;
3762   my $cdr = $opt{cdr};
3763   my $rate_detail = $opt{rate_detail};
3764   my $minutes = $opt{minutes};
3765   my $classnum = $rate_detail->classnum;
3766   my $pkgnum = $self->pkgnum;
3767   my $custnum = $self->custnum;
3768
3769   local $SIG{HUP} = 'IGNORE';
3770   local $SIG{INT} = 'IGNORE'; 
3771   local $SIG{QUIT} = 'IGNORE';
3772   local $SIG{TERM} = 'IGNORE';
3773   local $SIG{TSTP} = 'IGNORE'; 
3774   local $SIG{PIPE} = 'IGNORE'; 
3775
3776   my $oldAutoCommit = $FS::UID::AutoCommit;
3777   local $FS::UID::AutoCommit = 0;
3778   my $dbh = dbh;
3779   my $order = FS::Conf->new->config('cdr-minutes_priority');
3780
3781   my $is_classnum;
3782   if ( $classnum ) {
3783     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
3784   } else {
3785     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
3786   }
3787   my @usage_recs = qsearch({
3788       'table'     => 'cust_pkg_usage',
3789       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
3790                      ' JOIN cust_pkg             USING (pkgnum)'.
3791                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
3792       'select'    => 'cust_pkg_usage.*',
3793       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
3794                      " ( cust_pkg.custnum = $custnum AND ".
3795                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3796                      $is_classnum . ' AND '.
3797                      " cust_pkg_usage.minutes > 0",
3798       'order_by'  => " ORDER BY priority ASC",
3799   });
3800
3801   my $orig_minutes = $minutes;
3802   my $error;
3803   while (!$error and $minutes > 0 and @usage_recs) {
3804     my $cust_pkg_usage = shift @usage_recs;
3805     $cust_pkg_usage->select_for_update;
3806     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
3807         pkgusagenum => $cust_pkg_usage->pkgusagenum,
3808         acctid      => $cdr->acctid,
3809         minutes     => min($cust_pkg_usage->minutes, $minutes),
3810     });
3811     $cust_pkg_usage->set('minutes',
3812       sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes)
3813     );
3814     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
3815     $minutes -= $cdr_cust_pkg_usage->minutes;
3816   }
3817   if ( $order and $minutes > 0 and !$error ) {
3818     # then try to steal minutes from another call
3819     my %search = (
3820         'table'     => 'cdr_cust_pkg_usage',
3821         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
3822                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
3823                        ' JOIN cust_pkg              USING (pkgnum)'.
3824                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
3825                        ' JOIN cdr                   USING (acctid)',
3826         'select'    => 'cdr_cust_pkg_usage.*',
3827         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
3828                        " ( cust_pkg.pkgnum = $pkgnum OR ".
3829                        " ( cust_pkg.custnum = $custnum AND ".
3830                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3831                        " part_pkg_usage_class.classnum = $classnum",
3832         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
3833     );
3834     if ( $order eq 'time' ) {
3835       # find CDRs that are using minutes, but have a later startdate
3836       # than this call
3837       my $startdate = $cdr->startdate;
3838       if ($startdate !~ /^\d+$/) {
3839         die "bad cdr startdate '$startdate'";
3840       }
3841       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
3842       # minimize needless reshuffling
3843       $search{'order_by'} .= ', cdr.startdate DESC';
3844     } else {
3845       # XXX may not work correctly with rate_time schedules.  Could 
3846       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
3847       # think...
3848       $search{'addl_from'} .=
3849         ' JOIN rate_detail'.
3850         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
3851       if ( $order eq 'rate_high' ) {
3852         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
3853                                 $rate_detail->min_charge;
3854         $search{'order_by'} .= ', rate_detail.min_charge ASC';
3855       } elsif ( $order eq 'rate_low' ) {
3856         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
3857                                 $rate_detail->min_charge;
3858         $search{'order_by'} .= ', rate_detail.min_charge DESC';
3859       } else {
3860         #  this should really never happen
3861         die "invalid cdr-minutes_priority value '$order'\n";
3862       }
3863     }
3864     my @cdr_usage_recs = qsearch(\%search);
3865     my %reproc_cdrs;
3866     while (!$error and @cdr_usage_recs and $minutes > 0) {
3867       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
3868       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
3869       my $old_cdr = $cdr_cust_pkg_usage->cdr;
3870       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
3871       $cdr_cust_pkg_usage->select_for_update;
3872       $old_cdr->select_for_update;
3873       $cust_pkg_usage->select_for_update;
3874       # in case someone else stole the usage from this CDR
3875       # while waiting for the lock...
3876       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
3877       # steal the usage allocation and flag the old CDR for reprocessing
3878       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
3879       # if the allocation is more minutes than we need, adjust it...
3880       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
3881       if ( $delta > 0 ) {
3882         $cdr_cust_pkg_usage->set('minutes', $minutes);
3883         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
3884         $error = $cust_pkg_usage->replace;
3885       }
3886       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
3887       $error ||= $cdr_cust_pkg_usage->replace;
3888       # deduct the stolen minutes
3889       $minutes -= $cdr_cust_pkg_usage->minutes;
3890     }
3891     # after all minute-stealing is done, reset the affected CDRs
3892     foreach (values %reproc_cdrs) {
3893       $error ||= $_->set_status('');
3894       # XXX or should we just call $cdr->rate right here?
3895       # it's not like we can create a loop this way, since the min_charge
3896       # or call time has to go monotonically in one direction.
3897       # we COULD get some very deep recursions going, though...
3898     }
3899   } # if $order and $minutes
3900   if ( $error ) {
3901     $dbh->rollback;
3902     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
3903   } else {
3904     $dbh->commit if $oldAutoCommit;
3905     return $orig_minutes - $minutes;
3906   }
3907 }
3908
3909 =item supplemental_pkgs
3910
3911 Returns a list of all packages supplemental to this one.
3912
3913 =cut
3914
3915 sub supplemental_pkgs {
3916   my $self = shift;
3917   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
3918 }
3919
3920 =item main_pkg
3921
3922 Returns the package that this one is supplemental to, if any.
3923
3924 =cut
3925
3926 sub main_pkg {
3927   my $self = shift;
3928   if ( $self->main_pkgnum ) {
3929     return FS::cust_pkg->by_key($self->main_pkgnum);
3930   }
3931   return;
3932 }
3933
3934 =back
3935
3936 =head1 CLASS METHODS
3937
3938 =over 4
3939
3940 =item recurring_sql
3941
3942 Returns an SQL expression identifying recurring packages.
3943
3944 =cut
3945
3946 sub recurring_sql { "
3947   '0' != ( select freq from part_pkg
3948              where cust_pkg.pkgpart = part_pkg.pkgpart )
3949 "; }
3950
3951 =item onetime_sql
3952
3953 Returns an SQL expression identifying one-time packages.
3954
3955 =cut
3956
3957 sub onetime_sql { "
3958   '0' = ( select freq from part_pkg
3959             where cust_pkg.pkgpart = part_pkg.pkgpart )
3960 "; }
3961
3962 =item ordered_sql
3963
3964 Returns an SQL expression identifying ordered packages (recurring packages not
3965 yet billed).
3966
3967 =cut
3968
3969 sub ordered_sql {
3970    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
3971 }
3972
3973 =item active_sql
3974
3975 Returns an SQL expression identifying active packages.
3976
3977 =cut
3978
3979 sub active_sql {
3980   $_[0]->recurring_sql. "
3981   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3982   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3983   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3984 "; }
3985
3986 =item not_yet_billed_sql
3987
3988 Returns an SQL expression identifying packages which have not yet been billed.
3989
3990 =cut
3991
3992 sub not_yet_billed_sql { "
3993       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
3994   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3995   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3996 "; }
3997
3998 =item inactive_sql
3999
4000 Returns an SQL expression identifying inactive packages (one-time packages
4001 that are otherwise unsuspended/uncancelled).
4002
4003 =cut
4004
4005 sub inactive_sql { "
4006   ". $_[0]->onetime_sql(). "
4007   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4008   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4009   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4010 "; }
4011
4012 =item susp_sql
4013 =item suspended_sql
4014
4015 Returns an SQL expression identifying suspended packages.
4016
4017 =cut
4018
4019 sub suspended_sql { susp_sql(@_); }
4020 sub susp_sql {
4021   #$_[0]->recurring_sql(). ' AND '.
4022   "
4023         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4024     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4025   ";
4026 }
4027
4028 =item cancel_sql
4029 =item cancelled_sql
4030
4031 Returns an SQL exprression identifying cancelled packages.
4032
4033 =cut
4034
4035 sub cancelled_sql { cancel_sql(@_); }
4036 sub cancel_sql { 
4037   #$_[0]->recurring_sql(). ' AND '.
4038   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4039 }
4040
4041 =item status_sql
4042
4043 Returns an SQL expression to give the package status as a string.
4044
4045 =cut
4046
4047 sub status_sql {
4048 "CASE
4049   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4050   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4051   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4052   WHEN ".onetime_sql()." THEN 'one-time charge'
4053   ELSE 'active'
4054 END"
4055 }
4056
4057 =item search HASHREF
4058
4059 (Class method)
4060
4061 Returns a qsearch hash expression to search for parameters specified in HASHREF.
4062 Valid parameters are
4063
4064 =over 4
4065
4066 =item agentnum
4067
4068 =item magic
4069
4070 active, inactive, suspended, cancel (or cancelled)
4071
4072 =item status
4073
4074 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
4075
4076 =item custom
4077
4078  boolean selects custom packages
4079
4080 =item classnum
4081
4082 =item pkgpart
4083
4084 pkgpart or arrayref or hashref of pkgparts
4085
4086 =item setup
4087
4088 arrayref of beginning and ending epoch date
4089
4090 =item last_bill
4091
4092 arrayref of beginning and ending epoch date
4093
4094 =item bill
4095
4096 arrayref of beginning and ending epoch date
4097
4098 =item adjourn
4099
4100 arrayref of beginning and ending epoch date
4101
4102 =item susp
4103
4104 arrayref of beginning and ending epoch date
4105
4106 =item expire
4107
4108 arrayref of beginning and ending epoch date
4109
4110 =item cancel
4111
4112 arrayref of beginning and ending epoch date
4113
4114 =item query
4115
4116 pkgnum or APKG_pkgnum
4117
4118 =item cust_fields
4119
4120 a value suited to passing to FS::UI::Web::cust_header
4121
4122 =item CurrentUser
4123
4124 specifies the user for agent virtualization
4125
4126 =item fcc_line
4127
4128 boolean; if true, returns only packages with more than 0 FCC phone lines.
4129
4130 =item state, country
4131
4132 Limit to packages with a service location in the specified state and country.
4133 For FCC 477 reporting, mostly.
4134
4135 =back
4136
4137 =cut
4138
4139 sub search {
4140   my ($class, $params) = @_;
4141   my @where = ();
4142
4143   ##
4144   # parse agent
4145   ##
4146
4147   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
4148     push @where,
4149       "cust_main.agentnum = $1";
4150   }
4151
4152   ##
4153   # parse custnum
4154   ##
4155
4156   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
4157     push @where,
4158       "cust_pkg.custnum = $1";
4159   }
4160
4161   ##
4162   # custbatch
4163   ##
4164
4165   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
4166     push @where,
4167       "cust_pkg.pkgbatch = '$1'";
4168   }
4169
4170   ##
4171   # parse status
4172   ##
4173
4174   if (    $params->{'magic'}  eq 'active'
4175        || $params->{'status'} eq 'active' ) {
4176
4177     push @where, FS::cust_pkg->active_sql();
4178
4179   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
4180             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
4181
4182     push @where, FS::cust_pkg->not_yet_billed_sql();
4183
4184   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
4185             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
4186
4187     push @where, FS::cust_pkg->inactive_sql();
4188
4189   } elsif (    $params->{'magic'}  eq 'suspended'
4190             || $params->{'status'} eq 'suspended'  ) {
4191
4192     push @where, FS::cust_pkg->suspended_sql();
4193
4194   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
4195             || $params->{'status'} =~ /^cancell?ed$/ ) {
4196
4197     push @where, FS::cust_pkg->cancelled_sql();
4198
4199   }
4200
4201   ###
4202   # parse package class
4203   ###
4204
4205   if ( exists($params->{'classnum'}) ) {
4206
4207     my @classnum = ();
4208     if ( ref($params->{'classnum'}) ) {
4209
4210       if ( ref($params->{'classnum'}) eq 'HASH' ) {
4211         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
4212       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
4213         @classnum = @{ $params->{'classnum'} };
4214       } else {
4215         die 'unhandled classnum ref '. $params->{'classnum'};
4216       }
4217
4218
4219     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
4220       @classnum = ( $1 );
4221     }
4222
4223     if ( @classnum ) {
4224
4225       my @c_where = ();
4226       my @nums = grep $_, @classnum;
4227       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
4228       my $null = scalar( grep { $_ eq '' } @classnum );
4229       push @c_where, 'part_pkg.classnum IS NULL' if $null;
4230
4231       if ( scalar(@c_where) == 1 ) {
4232         push @where, @c_where;
4233       } elsif ( @c_where ) {
4234         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
4235       }
4236
4237     }
4238     
4239
4240   }
4241
4242   ###
4243   # parse package report options
4244   ###
4245
4246   my @report_option = ();
4247   if ( exists($params->{'report_option'}) ) {
4248     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
4249       @report_option = @{ $params->{'report_option'} };
4250     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
4251       @report_option = split(',', $1);
4252     }
4253
4254   }
4255
4256   if (@report_option) {
4257     # this will result in the empty set for the dangling comma case as it should
4258     push @where, 
4259       map{ "0 < ( SELECT count(*) FROM part_pkg_option
4260                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4261                     AND optionname = 'report_option_$_'
4262                     AND optionvalue = '1' )"
4263          } @report_option;
4264   }
4265
4266   foreach my $any ( grep /^report_option_any/, keys %$params ) {
4267
4268     my @report_option_any = ();
4269     if ( ref($params->{$any}) eq 'ARRAY' ) {
4270       @report_option_any = @{ $params->{$any} };
4271     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
4272       @report_option_any = split(',', $1);
4273     }
4274
4275     if (@report_option_any) {
4276       # this will result in the empty set for the dangling comma case as it should
4277       push @where, ' ( '. join(' OR ',
4278         map{ "0 < ( SELECT count(*) FROM part_pkg_option
4279                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4280                       AND optionname = 'report_option_$_'
4281                       AND optionvalue = '1' )"
4282            } @report_option_any
4283       ). ' ) ';
4284     }
4285
4286   }
4287
4288   ###
4289   # parse custom
4290   ###
4291
4292   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
4293
4294   ###
4295   # parse fcc_line
4296   ###
4297
4298   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
4299                                                         if $params->{fcc_line};
4300
4301   ###
4302   # parse censustract
4303   ###
4304
4305   if ( exists($params->{'censustract'}) ) {
4306     $params->{'censustract'} =~ /^([.\d]*)$/;
4307     my $censustract = "cust_location.censustract = '$1'";
4308     $censustract .= ' OR cust_location.censustract is NULL' unless $1;
4309     push @where,  "( $censustract )";
4310   }
4311
4312   ###
4313   # parse censustract2
4314   ###
4315   if ( exists($params->{'censustract2'})
4316        && $params->{'censustract2'} =~ /^(\d*)$/
4317      )
4318   {
4319     if ($1) {
4320       push @where, "cust_location.censustract LIKE '$1%'";
4321     } else {
4322       push @where,
4323         "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
4324     }
4325   }
4326
4327   ###
4328   # parse country/state
4329   ###
4330   for (qw(state country)) { # parsing rules are the same for these
4331   if ( exists($params->{$_}) 
4332     && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
4333     {
4334       # XXX post-2.3 only--before that, state/country may be in cust_main
4335       push @where, "cust_location.$_ = '$1'";
4336     }
4337   }
4338
4339   ###
4340   # parse part_pkg
4341   ###
4342
4343   if ( ref($params->{'pkgpart'}) ) {
4344
4345     my @pkgpart = ();
4346     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
4347       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
4348     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
4349       @pkgpart = @{ $params->{'pkgpart'} };
4350     } else {
4351       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
4352     }
4353
4354     @pkgpart = grep /^(\d+)$/, @pkgpart;
4355
4356     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
4357
4358   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4359     push @where, "pkgpart = $1";
4360   } 
4361
4362   ###
4363   # parse dates
4364   ###
4365
4366   my $orderby = '';
4367
4368   #false laziness w/report_cust_pkg.html
4369   my %disable = (
4370     'all'             => {},
4371     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
4372     'active'          => { 'susp'=>1, 'cancel'=>1 },
4373     'suspended'       => { 'cancel' => 1 },
4374     'cancelled'       => {},
4375     ''                => {},
4376   );
4377
4378   if( exists($params->{'active'} ) ) {
4379     # This overrides all the other date-related fields
4380     my($beginning, $ending) = @{$params->{'active'}};
4381     push @where,
4382       "cust_pkg.setup IS NOT NULL",
4383       "cust_pkg.setup <= $ending",
4384       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
4385       "NOT (".FS::cust_pkg->onetime_sql . ")";
4386   }
4387   else {
4388     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
4389
4390       next unless exists($params->{$field});
4391
4392       my($beginning, $ending) = @{$params->{$field}};
4393
4394       next if $beginning == 0 && $ending == 4294967295;
4395
4396       push @where,
4397         "cust_pkg.$field IS NOT NULL",
4398         "cust_pkg.$field >= $beginning",
4399         "cust_pkg.$field <= $ending";
4400
4401       $orderby ||= "ORDER BY cust_pkg.$field";
4402
4403     }
4404   }
4405
4406   $orderby ||= 'ORDER BY bill';
4407
4408   ###
4409   # parse magic, legacy, etc.
4410   ###
4411
4412   if ( $params->{'magic'} &&
4413        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
4414   ) {
4415
4416     $orderby = 'ORDER BY pkgnum';
4417
4418     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4419       push @where, "pkgpart = $1";
4420     }
4421
4422   } elsif ( $params->{'query'} eq 'pkgnum' ) {
4423
4424     $orderby = 'ORDER BY pkgnum';
4425
4426   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
4427
4428     $orderby = 'ORDER BY pkgnum';
4429
4430     push @where, '0 < (
4431       SELECT count(*) FROM pkg_svc
4432        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
4433          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
4434                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
4435                                      AND cust_svc.svcpart = pkg_svc.svcpart
4436                                 )
4437     )';
4438   
4439   }
4440
4441   ##
4442   # setup queries, links, subs, etc. for the search
4443   ##
4444
4445   # here is the agent virtualization
4446   if ($params->{CurrentUser}) {
4447     my $access_user =
4448       qsearchs('access_user', { username => $params->{CurrentUser} });
4449
4450     if ($access_user) {
4451       push @where, $access_user->agentnums_sql('table'=>'cust_main');
4452     } else {
4453       push @where, "1=0";
4454     }
4455   } else {
4456     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
4457   }
4458
4459   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
4460
4461   my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
4462                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
4463                   'LEFT JOIN cust_location USING ( locationnum ) '.
4464                   FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
4465
4466   my $select;
4467   my $count_query;
4468   if ( $params->{'select_zip5'} ) {
4469     my $zip = 'cust_location.zip';
4470
4471     $select = "DISTINCT substr($zip,1,5) as zip";
4472     $orderby = "ORDER BY substr($zip,1,5)";
4473     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
4474   } else {
4475     $select = join(', ',
4476                          'cust_pkg.*',
4477                          ( map "part_pkg.$_", qw( pkg freq ) ),
4478                          'pkg_class.classname',
4479                          'cust_main.custnum AS cust_main_custnum',
4480                          FS::UI::Web::cust_sql_fields(
4481                            $params->{'cust_fields'}
4482                          ),
4483                   );
4484     $count_query = 'SELECT COUNT(*)';
4485   }
4486
4487   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
4488
4489   my $sql_query = {
4490     'table'       => 'cust_pkg',
4491     'hashref'     => {},
4492     'select'      => $select,
4493     'extra_sql'   => $extra_sql,
4494     'order_by'    => $orderby,
4495     'addl_from'   => $addl_from,
4496     'count_query' => $count_query,
4497   };
4498
4499 }
4500
4501 =item fcc_477_count
4502
4503 Returns a list of two package counts.  The first is a count of packages
4504 based on the supplied criteria and the second is the count of residential
4505 packages with those same criteria.  Criteria are specified as in the search
4506 method.
4507
4508 =cut
4509
4510 sub fcc_477_count {
4511   my ($class, $params) = @_;
4512
4513   my $sql_query = $class->search( $params );
4514
4515   my $count_sql = delete($sql_query->{'count_query'});
4516   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4517     or die "couldn't parse count_sql";
4518
4519   my $count_sth = dbh->prepare($count_sql)
4520     or die "Error preparing $count_sql: ". dbh->errstr;
4521   $count_sth->execute
4522     or die "Error executing $count_sql: ". $count_sth->errstr;
4523   my $count_arrayref = $count_sth->fetchrow_arrayref;
4524
4525   return ( @$count_arrayref );
4526
4527 }
4528
4529 =item tax_locationnum_sql
4530
4531 Returns an SQL expression for the tax location for a package, based
4532 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4533
4534 =cut
4535
4536 sub tax_locationnum_sql {
4537   my $conf = FS::Conf->new;
4538   if ( $conf->exists('tax-pkg_address') ) {
4539     'cust_pkg.locationnum';
4540   }
4541   elsif ( $conf->exists('tax-ship_address') ) {
4542     'cust_main.ship_locationnum';
4543   }
4544   else {
4545     'cust_main.bill_locationnum';
4546   }
4547 }
4548
4549 =item location_sql
4550
4551 Returns a list: the first item is an SQL fragment identifying matching 
4552 packages/customers via location (taking into account shipping and package
4553 address taxation, if enabled), and subsequent items are the parameters to
4554 substitute for the placeholders in that fragment.
4555
4556 =cut
4557
4558 sub location_sql {
4559   my($class, %opt) = @_;
4560   my $ornull = $opt{'ornull'};
4561
4562   my $conf = new FS::Conf;
4563
4564   # '?' placeholders in _location_sql_where
4565   my $x = $ornull ? 3 : 2;
4566   my @bill_param = ( 
4567     ('district')x3,
4568     ('city')x3, 
4569     ('county')x$x,
4570     ('state')x$x,
4571     'country'
4572   );
4573
4574   my $main_where;
4575   my @main_param;
4576   if ( $conf->exists('tax-ship_address') ) {
4577
4578     $main_where = "(
4579          (     ( ship_last IS NULL     OR  ship_last  = '' )
4580            AND ". _location_sql_where('cust_main', '', $ornull ). "
4581          )
4582       OR (       ship_last IS NOT NULL AND ship_last != ''
4583            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4584          )
4585     )";
4586     #    AND payby != 'COMP'
4587
4588     @main_param = ( @bill_param, @bill_param );
4589
4590   } else {
4591
4592     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4593     @main_param = @bill_param;
4594
4595   }
4596
4597   my $where;
4598   my @param;
4599   if ( $conf->exists('tax-pkg_address') ) {
4600
4601     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4602
4603     $where = " (
4604                     ( cust_pkg.locationnum IS     NULL AND $main_where )
4605                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
4606                )
4607              ";
4608     @param = ( @main_param, @bill_param );
4609   
4610   } else {
4611
4612     $where = $main_where;
4613     @param = @main_param;
4614
4615   }
4616
4617   ( $where, @param );
4618
4619 }
4620
4621 #subroutine, helper for location_sql
4622 sub _location_sql_where {
4623   my $table  = shift;
4624   my $prefix = @_ ? shift : '';
4625   my $ornull = @_ ? shift : '';
4626
4627 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4628
4629   $ornull = $ornull ? ' OR ? IS NULL ' : '';
4630
4631   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
4632   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
4633   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
4634
4635   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4636
4637 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
4638   "
4639         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4640     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4641     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
4642     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
4643     AND   $table.${prefix}country  = ?
4644   ";
4645 }
4646
4647 sub _X_show_zero {
4648   my( $self, $what ) = @_;
4649
4650   my $what_show_zero = $what. '_show_zero';
4651   length($self->$what_show_zero())
4652     ? ($self->$what_show_zero() eq 'Y')
4653     : $self->part_pkg->$what_show_zero();
4654 }
4655
4656 =head1 SUBROUTINES
4657
4658 =over 4
4659
4660 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
4661
4662 CUSTNUM is a customer (see L<FS::cust_main>)
4663
4664 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4665 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
4666 permitted.
4667
4668 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
4669 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
4670 new billing items.  An error is returned if this is not possible (see
4671 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
4672 parameter.
4673
4674 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4675 newly-created cust_pkg objects.
4676
4677 REFNUM, if specified, will specify the FS::pkg_referral record to be created
4678 and inserted.  Multiple FS::pkg_referral records can be created by
4679 setting I<refnum> to an array reference of refnums or a hash reference with
4680 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
4681 record will be created corresponding to cust_main.refnum.
4682
4683 =cut
4684
4685 sub order {
4686   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
4687
4688   my $conf = new FS::Conf;
4689
4690   # Transactionize this whole mess
4691   local $SIG{HUP} = 'IGNORE';
4692   local $SIG{INT} = 'IGNORE'; 
4693   local $SIG{QUIT} = 'IGNORE';
4694   local $SIG{TERM} = 'IGNORE';
4695   local $SIG{TSTP} = 'IGNORE'; 
4696   local $SIG{PIPE} = 'IGNORE'; 
4697
4698   my $oldAutoCommit = $FS::UID::AutoCommit;
4699   local $FS::UID::AutoCommit = 0;
4700   my $dbh = dbh;
4701
4702   my $error;
4703 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
4704 #  return "Customer not found: $custnum" unless $cust_main;
4705
4706   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
4707     if $DEBUG;
4708
4709   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4710                          @$remove_pkgnum;
4711
4712   my $change = scalar(@old_cust_pkg) != 0;
4713
4714   my %hash = (); 
4715   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
4716
4717     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
4718          " to pkgpart ". $pkgparts->[0]. "\n"
4719       if $DEBUG;
4720
4721     my $err_or_cust_pkg =
4722       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
4723                                 'refnum'  => $refnum,
4724                               );
4725
4726     unless (ref($err_or_cust_pkg)) {
4727       $dbh->rollback if $oldAutoCommit;
4728       return $err_or_cust_pkg;
4729     }
4730
4731     push @$return_cust_pkg, $err_or_cust_pkg;
4732     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4733     return '';
4734
4735   }
4736
4737   # Create the new packages.
4738   foreach my $pkgpart (@$pkgparts) {
4739
4740     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
4741
4742     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
4743                                       pkgpart => $pkgpart,
4744                                       refnum  => $refnum,
4745                                       %hash,
4746                                     };
4747     $error = $cust_pkg->insert( 'change' => $change );
4748     push @$return_cust_pkg, $cust_pkg;
4749
4750     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
4751       my $supp_pkg = FS::cust_pkg->new({
4752           custnum => $custnum,
4753           pkgpart => $link->dst_pkgpart,
4754           refnum  => $refnum,
4755           main_pkgnum => $cust_pkg->pkgnum,
4756           %hash,
4757       });
4758       $error ||= $supp_pkg->insert( 'change' => $change );
4759       push @$return_cust_pkg, $supp_pkg;
4760     }
4761
4762     if ($error) {
4763       $dbh->rollback if $oldAutoCommit;
4764       return $error;
4765     }
4766
4767   }
4768   # $return_cust_pkg now contains refs to all of the newly 
4769   # created packages.
4770
4771   # Transfer services and cancel old packages.
4772   foreach my $old_pkg (@old_cust_pkg) {
4773
4774     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
4775       if $DEBUG;
4776
4777     foreach my $new_pkg (@$return_cust_pkg) {
4778       $error = $old_pkg->transfer($new_pkg);
4779       if ($error and $error == 0) {
4780         # $old_pkg->transfer failed.
4781         $dbh->rollback if $oldAutoCommit;
4782         return $error;
4783       }
4784     }
4785
4786     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
4787       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
4788       foreach my $new_pkg (@$return_cust_pkg) {
4789         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
4790         if ($error and $error == 0) {
4791           # $old_pkg->transfer failed.
4792         $dbh->rollback if $oldAutoCommit;
4793         return $error;
4794         }
4795       }
4796     }
4797
4798     if ($error > 0) {
4799       # Transfers were successful, but we went through all of the 
4800       # new packages and still had services left on the old package.
4801       # We can't cancel the package under the circumstances, so abort.
4802       $dbh->rollback if $oldAutoCommit;
4803       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
4804     }
4805     $error = $old_pkg->cancel( quiet=>1 );
4806     if ($error) {
4807       $dbh->rollback;
4808       return $error;
4809     }
4810   }
4811   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4812   '';
4813 }
4814
4815 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
4816
4817 A bulk change method to change packages for multiple customers.
4818
4819 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4820 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
4821 permitted.
4822
4823 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
4824 replace.  The services (see L<FS::cust_svc>) are moved to the
4825 new billing items.  An error is returned if this is not possible (see
4826 L<FS::pkg_svc>).
4827
4828 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4829 newly-created cust_pkg objects.
4830
4831 =cut
4832
4833 sub bulk_change {
4834   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
4835
4836   # Transactionize this whole mess
4837   local $SIG{HUP} = 'IGNORE';
4838   local $SIG{INT} = 'IGNORE'; 
4839   local $SIG{QUIT} = 'IGNORE';
4840   local $SIG{TERM} = 'IGNORE';
4841   local $SIG{TSTP} = 'IGNORE'; 
4842   local $SIG{PIPE} = 'IGNORE'; 
4843
4844   my $oldAutoCommit = $FS::UID::AutoCommit;
4845   local $FS::UID::AutoCommit = 0;
4846   my $dbh = dbh;
4847
4848   my @errors;
4849   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4850                          @$remove_pkgnum;
4851
4852   while(scalar(@old_cust_pkg)) {
4853     my @return = ();
4854     my $custnum = $old_cust_pkg[0]->custnum;
4855     my (@remove) = map { $_->pkgnum }
4856                    grep { $_->custnum == $custnum } @old_cust_pkg;
4857     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
4858
4859     my $error = order $custnum, $pkgparts, \@remove, \@return;
4860
4861     push @errors, $error
4862       if $error;
4863     push @$return_cust_pkg, @return;
4864   }
4865
4866   if (scalar(@errors)) {
4867     $dbh->rollback if $oldAutoCommit;
4868     return join(' / ', @errors);
4869   }
4870
4871   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4872   '';
4873 }
4874
4875 # Used by FS::Upgrade to migrate to a new database.
4876 sub _upgrade_data {  # class method
4877   my ($class, %opts) = @_;
4878   $class->_upgrade_otaker(%opts);
4879   my @statements = (
4880     # RT#10139, bug resulting in contract_end being set when it shouldn't
4881   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
4882     # RT#10830, bad calculation of prorate date near end of year
4883     # the date range for bill is December 2009, and we move it forward
4884     # one year if it's before the previous bill date (which it should 
4885     # never be)
4886   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
4887   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
4888   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
4889     # RT6628, add order_date to cust_pkg
4890     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
4891         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
4892         history_action = \'insert\') where order_date is null',
4893   );
4894   foreach my $sql (@statements) {
4895     my $sth = dbh->prepare($sql);
4896     $sth->execute or die $sth->errstr;
4897   }
4898 }
4899
4900 =back
4901
4902 =head1 BUGS
4903
4904 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
4905
4906 In sub order, the @pkgparts array (passed by reference) is clobbered.
4907
4908 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
4909 method to pass dates to the recur_prog expression, it should do so.
4910
4911 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
4912 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
4913 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
4914 configuration values.  Probably need a subroutine which decides what to do
4915 based on whether or not we've fetched the user yet, rather than a hash.  See
4916 FS::UID and the TODO.
4917
4918 Now that things are transactional should the check in the insert method be
4919 moved to check ?
4920
4921 =head1 SEE ALSO
4922
4923 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
4924 L<FS::pkg_svc>, schema.html from the base documentation
4925
4926 =cut
4927
4928 1;
4929