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