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