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