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