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