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 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
2617
2618 Returns the services for this package, as FS::cust_svc objects (see
2619 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
2620 spcififed, returns only the matching services.
2621
2622 As an optimization, use the cust_svc_unsorted version if you are not displaying
2623 the results.
2624
2625 =cut
2626
2627 sub cust_svc {
2628   my $self = shift;
2629   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
2630   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref );
2631 }
2632
2633 sub cust_svc_unsorted {
2634   my $self = shift;
2635   @{ $self->cust_svc_unsorted_arrayref };
2636 }
2637
2638 sub cust_svc_unsorted_arrayref {
2639   my $self = shift;
2640
2641   return () unless $self->num_cust_svc(@_);
2642
2643   my %opt = ();
2644   if ( @_ && $_[0] =~ /^\d+/ ) {
2645     $opt{svcpart} = shift;
2646   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2647     %opt = %{ $_[0] };
2648   } elsif ( @_ ) {
2649     %opt = @_;
2650   }
2651
2652   my %search = (
2653     'table'   => 'cust_svc',
2654     'hashref' => { 'pkgnum' => $self->pkgnum },
2655   );
2656   if ( $opt{svcpart} ) {
2657     $search{hashref}->{svcpart} = $opt{'svcpart'};
2658   }
2659   if ( $opt{'svcdb'} ) {
2660     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
2661     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
2662   }
2663
2664   [ qsearch(\%search) ];
2665
2666 }
2667
2668 =item overlimit [ SVCPART ]
2669
2670 Returns the services for this package which have exceeded their
2671 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
2672 is specified, return only the matching services.
2673
2674 =cut
2675
2676 sub overlimit {
2677   my $self = shift;
2678   return () unless $self->num_cust_svc(@_);
2679   grep { $_->overlimit } $self->cust_svc(@_);
2680 }
2681
2682 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2683
2684 Returns historical services for this package created before END TIMESTAMP and
2685 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
2686 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
2687 I<pkg_svc.hidden> flag will be omitted.
2688
2689 =cut
2690
2691 sub h_cust_svc {
2692   my $self = shift;
2693   warn "$me _h_cust_svc called on $self\n"
2694     if $DEBUG;
2695
2696   my ($end, $start, $mode) = @_;
2697   my @cust_svc = $self->_sort_cust_svc(
2698     [ qsearch( 'h_cust_svc',
2699       { 'pkgnum' => $self->pkgnum, },  
2700       FS::h_cust_svc->sql_h_search(@_),  
2701     ) ]
2702   );
2703   if ( defined($mode) && $mode eq 'I' ) {
2704     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
2705     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
2706   } else {
2707     return @cust_svc;
2708   }
2709 }
2710
2711 sub _sort_cust_svc {
2712   my( $self, $arrayref ) = @_;
2713
2714   my $sort =
2715     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
2716
2717   my %pkg_svc = map { $_->svcpart => $_ }
2718                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
2719
2720   map  { $_->[0] }
2721   sort $sort
2722   map {
2723         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
2724         [ $_,
2725           $pkg_svc ? $pkg_svc->primary_svc : '',
2726           $pkg_svc ? $pkg_svc->quantity : 0,
2727         ];
2728       }
2729   @$arrayref;
2730
2731 }
2732
2733 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
2734
2735 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
2736
2737 Returns the number of services for this package.  Available options are svcpart
2738 and svcdb.  If either is spcififed, returns only the matching services.
2739
2740 =cut
2741
2742 sub num_cust_svc {
2743   my $self = shift;
2744
2745   return $self->{'_num_cust_svc'}
2746     if !scalar(@_)
2747        && exists($self->{'_num_cust_svc'})
2748        && $self->{'_num_cust_svc'} =~ /\d/;
2749
2750   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
2751     if $DEBUG > 2;
2752
2753   my %opt = ();
2754   if ( @_ && $_[0] =~ /^\d+/ ) {
2755     $opt{svcpart} = shift;
2756   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2757     %opt = %{ $_[0] };
2758   } elsif ( @_ ) {
2759     %opt = @_;
2760   }
2761
2762   my $select = 'SELECT COUNT(*) FROM cust_svc ';
2763   my $where = ' WHERE pkgnum = ? ';
2764   my @param = ($self->pkgnum);
2765
2766   if ( $opt{'svcpart'} ) {
2767     $where .= ' AND svcpart = ? ';
2768     push @param, $opt{'svcpart'};
2769   }
2770   if ( $opt{'svcdb'} ) {
2771     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
2772     $where .= ' AND svcdb = ? ';
2773     push @param, $opt{'svcdb'};
2774   }
2775
2776   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
2777   $sth->execute(@param) or die $sth->errstr;
2778   $sth->fetchrow_arrayref->[0];
2779 }
2780
2781 =item available_part_svc 
2782
2783 Returns a list of FS::part_svc objects representing services included in this
2784 package but not yet provisioned.  Each FS::part_svc object also has an extra
2785 field, I<num_avail>, which specifies the number of available services.
2786
2787 =cut
2788
2789 sub available_part_svc {
2790   my $self = shift;
2791
2792   my $pkg_quantity = $self->quantity || 1;
2793
2794   grep { $_->num_avail > 0 }
2795     map {
2796           my $part_svc = $_->part_svc;
2797           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
2798             $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
2799
2800           # more evil encapsulation breakage
2801           if($part_svc->{'Hash'}{'num_avail'} > 0) {
2802             my @exports = $part_svc->part_export_did;
2803             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
2804           }
2805
2806           $part_svc;
2807         }
2808       $self->part_pkg->pkg_svc;
2809 }
2810
2811 =item part_svc [ OPTION => VALUE ... ]
2812
2813 Returns a list of FS::part_svc objects representing provisioned and available
2814 services included in this package.  Each FS::part_svc object also has the
2815 following extra fields:
2816
2817 =over 4
2818
2819 =item num_cust_svc  (count)
2820
2821 =item num_avail     (quantity - count)
2822
2823 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
2824
2825 =back
2826
2827 Accepts one option: summarize_size.  If specified and non-zero, will omit the
2828 extra cust_pkg_svc option for objects where num_cust_svc is this size or
2829 greater.
2830
2831 =cut
2832
2833 #svcnum
2834 #label -> ($cust_svc->label)[1]
2835
2836 sub part_svc {
2837   my $self = shift;
2838   my %opt = @_;
2839
2840   my $pkg_quantity = $self->quantity || 1;
2841
2842   #XXX some sort of sort order besides numeric by svcpart...
2843   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
2844     my $pkg_svc = $_;
2845     my $part_svc = $pkg_svc->part_svc;
2846     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2847     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
2848     $part_svc->{'Hash'}{'num_avail'}    =
2849       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
2850     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2851         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
2852       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
2853           && $num_cust_svc >= $opt{summarize_size};
2854     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
2855     $part_svc;
2856   } $self->part_pkg->pkg_svc;
2857
2858   #extras
2859   push @part_svc, map {
2860     my $part_svc = $_;
2861     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2862     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
2863     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
2864     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2865       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
2866     $part_svc;
2867   } $self->extra_part_svc;
2868
2869   @part_svc;
2870
2871 }
2872
2873 =item extra_part_svc
2874
2875 Returns a list of FS::part_svc objects corresponding to services in this
2876 package which are still provisioned but not (any longer) available in the
2877 package definition.
2878
2879 =cut
2880
2881 sub extra_part_svc {
2882   my $self = shift;
2883
2884   my $pkgnum  = $self->pkgnum;
2885   #my $pkgpart = $self->pkgpart;
2886
2887 #  qsearch( {
2888 #    'table'     => 'part_svc',
2889 #    'hashref'   => {},
2890 #    'extra_sql' =>
2891 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
2892 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
2893 #                       AND pkg_svc.pkgpart = ?
2894 #                       AND quantity > 0 
2895 #                 )
2896 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
2897 #                       LEFT JOIN cust_pkg USING ( pkgnum )
2898 #                     WHERE cust_svc.svcpart = part_svc.svcpart
2899 #                       AND pkgnum = ?
2900 #                 )",
2901 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
2902 #  } );
2903
2904 #seems to benchmark slightly faster... (or did?)
2905
2906   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
2907   my $pkgparts = join(',', @pkgparts);
2908
2909   qsearch( {
2910     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
2911     #MySQL doesn't grok DISINCT ON
2912     'select'      => 'DISTINCT part_svc.*',
2913     'table'       => 'part_svc',
2914     'addl_from'   =>
2915       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
2916                                AND pkg_svc.pkgpart IN ($pkgparts)
2917                                AND quantity > 0
2918                              )
2919        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
2920        LEFT JOIN cust_pkg USING ( pkgnum )
2921       ",
2922     'hashref'     => {},
2923     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
2924     'extra_param' => [ [$self->pkgnum=>'int'] ],
2925   } );
2926 }
2927
2928 =item status
2929
2930 Returns a short status string for this package, currently:
2931
2932 =over 4
2933
2934 =item not yet billed
2935
2936 =item one-time charge
2937
2938 =item active
2939
2940 =item suspended
2941
2942 =item cancelled
2943
2944 =back
2945
2946 =cut
2947
2948 sub status {
2949   my $self = shift;
2950
2951   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2952
2953   return 'cancelled' if $self->get('cancel');
2954   return 'suspended' if $self->susp;
2955   return 'not yet billed' unless $self->setup;
2956   return 'one-time charge' if $freq =~ /^(0|$)/;
2957   return 'active';
2958 }
2959
2960 =item ucfirst_status
2961
2962 Returns the status with the first character capitalized.
2963
2964 =cut
2965
2966 sub ucfirst_status {
2967   ucfirst(shift->status);
2968 }
2969
2970 =item statuses
2971
2972 Class method that returns the list of possible status strings for packages
2973 (see L<the status method|/status>).  For example:
2974
2975   @statuses = FS::cust_pkg->statuses();
2976
2977 =cut
2978
2979 tie my %statuscolor, 'Tie::IxHash', 
2980   'not yet billed'  => '009999', #teal? cyan?
2981   'one-time charge' => '000000',
2982   'active'          => '00CC00',
2983   'suspended'       => 'FF9900',
2984   'cancelled'       => 'FF0000',
2985 ;
2986
2987 sub statuses {
2988   my $self = shift; #could be class...
2989   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2990   #                                    # mayble split btw one-time vs. recur
2991     keys %statuscolor;
2992 }
2993
2994 =item statuscolor
2995
2996 Returns a hex triplet color string for this package's status.
2997
2998 =cut
2999
3000 sub statuscolor {
3001   my $self = shift;
3002   $statuscolor{$self->status};
3003 }
3004
3005 =item pkg_label
3006
3007 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3008 "pkg - comment" depending on user preference).
3009
3010 =cut
3011
3012 sub pkg_label {
3013   my $self = shift;
3014   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
3015   $label = $self->pkgnum. ": $label"
3016     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3017   $label;
3018 }
3019
3020 =item pkg_label_long
3021
3022 Returns a long label for this package, adding the primary service's label to
3023 pkg_label.
3024
3025 =cut
3026
3027 sub pkg_label_long {
3028   my $self = shift;
3029   my $label = $self->pkg_label;
3030   my $cust_svc = $self->primary_cust_svc;
3031   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3032   $label;
3033 }
3034
3035 =item pkg_locale
3036
3037 Returns a customer-localized label for this package.
3038
3039 =cut
3040
3041 sub pkg_locale {
3042   my $self = shift;
3043   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3044 }
3045
3046 =item primary_cust_svc
3047
3048 Returns a primary service (as FS::cust_svc object) if one can be identified.
3049
3050 =cut
3051
3052 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3053
3054 sub primary_cust_svc {
3055   my $self = shift;
3056
3057   my @cust_svc = $self->cust_svc;
3058
3059   return '' unless @cust_svc; #no serivces - irrelevant then
3060   
3061   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3062
3063   # primary service as specified in the package definition
3064   # or exactly one service definition with quantity one
3065   my $svcpart = $self->part_pkg->svcpart;
3066   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3067   return $cust_svc[0] if scalar(@cust_svc) == 1;
3068
3069   #couldn't identify one thing..
3070   return '';
3071 }
3072
3073 =item labels
3074
3075 Returns a list of lists, calling the label method for all services
3076 (see L<FS::cust_svc>) of this billing item.
3077
3078 =cut
3079
3080 sub labels {
3081   my $self = shift;
3082   map { [ $_->label ] } $self->cust_svc;
3083 }
3084
3085 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3086
3087 Like the labels method, but returns historical information on services that
3088 were active as of END_TIMESTAMP and (optionally) not cancelled before
3089 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3090 I<pkg_svc.hidden> flag will be omitted.
3091
3092 Returns a list of lists, calling the label method for all (historical) services
3093 (see L<FS::h_cust_svc>) of this billing item.
3094
3095 =cut
3096
3097 sub h_labels {
3098   my $self = shift;
3099   warn "$me _h_labels called on $self\n"
3100     if $DEBUG;
3101   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3102 }
3103
3104 =item labels_short
3105
3106 Like labels, except returns a simple flat list, and shortens long
3107 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3108 identical services to one line that lists the service label and the number of
3109 individual services rather than individual items.
3110
3111 =cut
3112
3113 sub labels_short {
3114   shift->_labels_short( 'labels', @_ );
3115 }
3116
3117 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3118
3119 Like h_labels, except returns a simple flat list, and shortens long
3120 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3121 identical services to one line that lists the service label and the number of
3122 individual services rather than individual items.
3123
3124 =cut
3125
3126 sub h_labels_short {
3127   shift->_labels_short( 'h_labels', @_ );
3128 }
3129
3130 sub _labels_short {
3131   my( $self, $method ) = ( shift, shift );
3132
3133   warn "$me _labels_short called on $self with $method method\n"
3134     if $DEBUG;
3135
3136   my $conf = new FS::Conf;
3137   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3138
3139   warn "$me _labels_short populating \%labels\n"
3140     if $DEBUG;
3141
3142   my %labels;
3143   #tie %labels, 'Tie::IxHash';
3144   push @{ $labels{$_->[0]} }, $_->[1]
3145     foreach $self->$method(@_);
3146
3147   warn "$me _labels_short populating \@labels\n"
3148     if $DEBUG;
3149
3150   my @labels;
3151   foreach my $label ( keys %labels ) {
3152     my %seen = ();
3153     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3154     my $num = scalar(@values);
3155     warn "$me _labels_short $num items for $label\n"
3156       if $DEBUG;
3157
3158     if ( $num > $max_same_services ) {
3159       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3160         if $DEBUG;
3161       push @labels, "$label ($num)";
3162     } else {
3163       if ( $conf->exists('cust_bill-consolidate_services') ) {
3164         warn "$me _labels_short   consolidating services\n"
3165           if $DEBUG;
3166         # push @labels, "$label: ". join(', ', @values);
3167         while ( @values ) {
3168           my $detail = "$label: ";
3169           $detail .= shift(@values). ', '
3170             while @values
3171                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3172           $detail =~ s/, $//;
3173           push @labels, $detail;
3174         }
3175         warn "$me _labels_short   done consolidating services\n"
3176           if $DEBUG;
3177       } else {
3178         warn "$me _labels_short   adding service data\n"
3179           if $DEBUG;
3180         push @labels, map { "$label: $_" } @values;
3181       }
3182     }
3183   }
3184
3185  @labels;
3186
3187 }
3188
3189 =item cust_main
3190
3191 Returns the parent customer object (see L<FS::cust_main>).
3192
3193 =cut
3194
3195 sub cust_main {
3196   my $self = shift;
3197   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
3198 }
3199
3200 =item balance
3201
3202 Returns the balance for this specific package, when using
3203 experimental package balance.
3204
3205 =cut
3206
3207 sub balance {
3208   my $self = shift;
3209   $self->cust_main->balance_pkgnum( $self->pkgnum );
3210 }
3211
3212 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3213
3214 =item cust_location
3215
3216 Returns the location object, if any (see L<FS::cust_location>).
3217
3218 =item cust_location_or_main
3219
3220 If this package is associated with a location, returns the locaiton (see
3221 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3222
3223 =item location_label [ OPTION => VALUE ... ]
3224
3225 Returns the label of the location object (see L<FS::cust_location>).
3226
3227 =cut
3228
3229 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3230
3231 =item tax_locationnum
3232
3233 Returns the foreign key to a L<FS::cust_location> object for calculating  
3234 tax on this package, as determined by the C<tax-pkg_address> and 
3235 C<tax-ship_address> configuration flags.
3236
3237 =cut
3238
3239 sub tax_locationnum {
3240   my $self = shift;
3241   my $conf = FS::Conf->new;
3242   if ( $conf->exists('tax-pkg_address') ) {
3243     return $self->locationnum;
3244   }
3245   elsif ( $conf->exists('tax-ship_address') ) {
3246     return $self->cust_main->ship_locationnum;
3247   }
3248   else {
3249     return $self->cust_main->bill_locationnum;
3250   }
3251 }
3252
3253 =item tax_location
3254
3255 Returns the L<FS::cust_location> object for tax_locationnum.
3256
3257 =cut
3258
3259 sub tax_location {
3260   my $self = shift;
3261   FS::cust_location->by_key( $self->tax_locationnum )
3262 }
3263
3264 =item seconds_since TIMESTAMP
3265
3266 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3267 package have been online since TIMESTAMP, according to the session monitor.
3268
3269 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3270 L<Time::Local> and L<Date::Parse> for conversion functions.
3271
3272 =cut
3273
3274 sub seconds_since {
3275   my($self, $since) = @_;
3276   my $seconds = 0;
3277
3278   foreach my $cust_svc (
3279     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3280   ) {
3281     $seconds += $cust_svc->seconds_since($since);
3282   }
3283
3284   $seconds;
3285
3286 }
3287
3288 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3289
3290 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3291 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3292 (exclusive).
3293
3294 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3295 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3296 functions.
3297
3298
3299 =cut
3300
3301 sub seconds_since_sqlradacct {
3302   my($self, $start, $end) = @_;
3303
3304   my $seconds = 0;
3305
3306   foreach my $cust_svc (
3307     grep {
3308       my $part_svc = $_->part_svc;
3309       $part_svc->svcdb eq 'svc_acct'
3310         && scalar($part_svc->part_export_usage);
3311     } $self->cust_svc
3312   ) {
3313     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
3314   }
3315
3316   $seconds;
3317
3318 }
3319
3320 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
3321
3322 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
3323 in this package for sessions ending between TIMESTAMP_START (inclusive) and
3324 TIMESTAMP_END
3325 (exclusive).
3326
3327 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3328 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3329 functions.
3330
3331 =cut
3332
3333 sub attribute_since_sqlradacct {
3334   my($self, $start, $end, $attrib) = @_;
3335
3336   my $sum = 0;
3337
3338   foreach my $cust_svc (
3339     grep {
3340       my $part_svc = $_->part_svc;
3341       $part_svc->svcdb eq 'svc_acct'
3342         && scalar($part_svc->part_export_usage);
3343     } $self->cust_svc
3344   ) {
3345     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
3346   }
3347
3348   $sum;
3349
3350 }
3351
3352 =item quantity
3353
3354 =cut
3355
3356 sub quantity {
3357   my( $self, $value ) = @_;
3358   if ( defined($value) ) {
3359     $self->setfield('quantity', $value);
3360   }
3361   $self->getfield('quantity') || 1;
3362 }
3363
3364 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
3365
3366 Transfers as many services as possible from this package to another package.
3367
3368 The destination package can be specified by pkgnum by passing an FS::cust_pkg
3369 object.  The destination package must already exist.
3370
3371 Services are moved only if the destination allows services with the correct
3372 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
3373 this option with caution!  No provision is made for export differences
3374 between the old and new service definitions.  Probably only should be used
3375 when your exports for all service definitions of a given svcdb are identical.
3376 (attempt a transfer without it first, to move all possible svcpart-matching
3377 services)
3378
3379 Any services that can't be moved remain in the original package.
3380
3381 Returns an error, if there is one; otherwise, returns the number of services 
3382 that couldn't be moved.
3383
3384 =cut
3385
3386 sub transfer {
3387   my ($self, $dest_pkgnum, %opt) = @_;
3388
3389   my $remaining = 0;
3390   my $dest;
3391   my %target;
3392
3393   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
3394     $dest = $dest_pkgnum;
3395     $dest_pkgnum = $dest->pkgnum;
3396   } else {
3397     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
3398   }
3399
3400   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
3401
3402   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
3403     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
3404   }
3405
3406   foreach my $cust_svc ($dest->cust_svc) {
3407     $target{$cust_svc->svcpart}--;
3408   }
3409
3410   my %svcpart2svcparts = ();
3411   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3412     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
3413     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
3414       next if exists $svcpart2svcparts{$svcpart};
3415       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
3416       $svcpart2svcparts{$svcpart} = [
3417         map  { $_->[0] }
3418         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
3419         map {
3420               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
3421                                                    'svcpart' => $_          } );
3422               [ $_,
3423                 $pkg_svc ? $pkg_svc->primary_svc : '',
3424                 $pkg_svc ? $pkg_svc->quantity : 0,
3425               ];
3426             }
3427
3428         grep { $_ != $svcpart }
3429         map  { $_->svcpart }
3430         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
3431       ];
3432       warn "alternates for svcpart $svcpart: ".
3433            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
3434         if $DEBUG;
3435     }
3436   }
3437
3438   foreach my $cust_svc ($self->cust_svc) {
3439     if($target{$cust_svc->svcpart} > 0
3440        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
3441       $target{$cust_svc->svcpart}--;
3442       my $new = new FS::cust_svc { $cust_svc->hash };
3443       $new->pkgnum($dest_pkgnum);
3444       my $error = $new->replace($cust_svc);
3445       return $error if $error;
3446     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3447       if ( $DEBUG ) {
3448         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
3449         warn "alternates to consider: ".
3450              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
3451       }
3452       my @alternate = grep {
3453                              warn "considering alternate svcpart $_: ".
3454                                   "$target{$_} available in new package\n"
3455                                if $DEBUG;
3456                              $target{$_} > 0;
3457                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
3458       if ( @alternate ) {
3459         warn "alternate(s) found\n" if $DEBUG;
3460         my $change_svcpart = $alternate[0];
3461         $target{$change_svcpart}--;
3462         my $new = new FS::cust_svc { $cust_svc->hash };
3463         $new->svcpart($change_svcpart);
3464         $new->pkgnum($dest_pkgnum);
3465         my $error = $new->replace($cust_svc);
3466         return $error if $error;
3467       } else {
3468         $remaining++;
3469       }
3470     } else {
3471       $remaining++
3472     }
3473   }
3474   return $remaining;
3475 }
3476
3477 =item grab_svcnums SVCNUM, SVCNUM ...
3478
3479 Change the pkgnum for the provided services to this packages.  If there is an
3480 error, returns the error, otherwise returns false.
3481
3482 =cut
3483
3484 sub grab_svcnums {
3485   my $self = shift;
3486   my @svcnum = @_;
3487
3488   local $SIG{HUP} = 'IGNORE';
3489   local $SIG{INT} = 'IGNORE';
3490   local $SIG{QUIT} = 'IGNORE';
3491   local $SIG{TERM} = 'IGNORE';
3492   local $SIG{TSTP} = 'IGNORE';
3493   local $SIG{PIPE} = 'IGNORE';
3494
3495   my $oldAutoCommit = $FS::UID::AutoCommit;
3496   local $FS::UID::AutoCommit = 0;
3497   my $dbh = dbh;
3498
3499   foreach my $svcnum (@svcnum) {
3500     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
3501       $dbh->rollback if $oldAutoCommit;
3502       return "unknown svcnum $svcnum";
3503     };
3504     $cust_svc->pkgnum( $self->pkgnum );
3505     my $error = $cust_svc->replace;
3506     if ( $error ) {
3507       $dbh->rollback if $oldAutoCommit;
3508       return $error;
3509     }
3510   }
3511
3512   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3513   '';
3514
3515 }
3516
3517 =item reexport
3518
3519 This method is deprecated.  See the I<depend_jobnum> option to the insert and
3520 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
3521
3522 =cut
3523
3524 #looks like this is still used by the order_pkg and change_pkg methods in
3525 # ClientAPI/MyAccount, need to look into those before removing
3526 sub reexport {
3527   my $self = shift;
3528
3529   local $SIG{HUP} = 'IGNORE';
3530   local $SIG{INT} = 'IGNORE';
3531   local $SIG{QUIT} = 'IGNORE';
3532   local $SIG{TERM} = 'IGNORE';
3533   local $SIG{TSTP} = 'IGNORE';
3534   local $SIG{PIPE} = 'IGNORE';
3535
3536   my $oldAutoCommit = $FS::UID::AutoCommit;
3537   local $FS::UID::AutoCommit = 0;
3538   my $dbh = dbh;
3539
3540   foreach my $cust_svc ( $self->cust_svc ) {
3541     #false laziness w/svc_Common::insert
3542     my $svc_x = $cust_svc->svc_x;
3543     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
3544       my $error = $part_export->export_insert($svc_x);
3545       if ( $error ) {
3546         $dbh->rollback if $oldAutoCommit;
3547         return $error;
3548       }
3549     }
3550   }
3551
3552   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3553   '';
3554
3555 }
3556
3557 =item export_pkg_change OLD_CUST_PKG
3558
3559 Calls the "pkg_change" export action for all services attached to this package.
3560
3561 =cut
3562
3563 sub export_pkg_change {
3564   my( $self, $old )  = ( shift, shift );
3565
3566   local $SIG{HUP} = 'IGNORE';
3567   local $SIG{INT} = 'IGNORE';
3568   local $SIG{QUIT} = 'IGNORE';
3569   local $SIG{TERM} = 'IGNORE';
3570   local $SIG{TSTP} = 'IGNORE';
3571   local $SIG{PIPE} = 'IGNORE';
3572
3573   my $oldAutoCommit = $FS::UID::AutoCommit;
3574   local $FS::UID::AutoCommit = 0;
3575   my $dbh = dbh;
3576
3577   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
3578     my $error = $svc_x->export('pkg_change', $self, $old);
3579     if ( $error ) {
3580       $dbh->rollback if $oldAutoCommit;
3581       return $error;
3582     }
3583   }
3584
3585   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3586   '';
3587
3588 }
3589
3590 =item insert_reason
3591
3592 Associates this package with a (suspension or cancellation) reason (see
3593 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
3594 L<FS::reason>).
3595
3596 Available options are:
3597
3598 =over 4
3599
3600 =item reason
3601
3602 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.
3603
3604 =item reason_otaker
3605
3606 the access_user (see L<FS::access_user>) providing the reason
3607
3608 =item date
3609
3610 a unix timestamp 
3611
3612 =item action
3613
3614 the action (cancel, susp, adjourn, expire) associated with the reason
3615
3616 =back
3617
3618 If there is an error, returns the error, otherwise returns false.
3619
3620 =cut
3621
3622 sub insert_reason {
3623   my ($self, %options) = @_;
3624
3625   my $otaker = $options{reason_otaker} ||
3626                $FS::CurrentUser::CurrentUser->username;
3627
3628   my $reasonnum;
3629   if ( $options{'reason'} =~ /^(\d+)$/ ) {
3630
3631     $reasonnum = $1;
3632
3633   } elsif ( ref($options{'reason'}) ) {
3634   
3635     return 'Enter a new reason (or select an existing one)'
3636       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
3637
3638     my $reason = new FS::reason({
3639       'reason_type' => $options{'reason'}->{'typenum'},
3640       'reason'      => $options{'reason'}->{'reason'},
3641     });
3642     my $error = $reason->insert;
3643     return $error if $error;
3644
3645     $reasonnum = $reason->reasonnum;
3646
3647   } else {
3648     return "Unparsable reason: ". $options{'reason'};
3649   }
3650
3651   my $cust_pkg_reason =
3652     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
3653                               'reasonnum' => $reasonnum, 
3654                               'otaker'    => $otaker,
3655                               'action'    => substr(uc($options{'action'}),0,1),
3656                               'date'      => $options{'date'}
3657                                                ? $options{'date'}
3658                                                : time,
3659                             });
3660
3661   $cust_pkg_reason->insert;
3662 }
3663
3664 =item insert_discount
3665
3666 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
3667 inserting a new discount on the fly (see L<FS::discount>).
3668
3669 Available options are:
3670
3671 =over 4
3672
3673 =item discountnum
3674
3675 =back
3676
3677 If there is an error, returns the error, otherwise returns false.
3678
3679 =cut
3680
3681 sub insert_discount {
3682   #my ($self, %options) = @_;
3683   my $self = shift;
3684
3685   my $cust_pkg_discount = new FS::cust_pkg_discount {
3686     'pkgnum'      => $self->pkgnum,
3687     'discountnum' => $self->discountnum,
3688     'months_used' => 0,
3689     'end_date'    => '', #XXX
3690     #for the create a new discount case
3691     '_type'       => $self->discountnum__type,
3692     'amount'      => $self->discountnum_amount,
3693     'percent'     => $self->discountnum_percent,
3694     'months'      => $self->discountnum_months,
3695     'setup'      => $self->discountnum_setup,
3696     #'disabled'    => $self->discountnum_disabled,
3697   };
3698
3699   $cust_pkg_discount->insert;
3700 }
3701
3702 =item set_usage USAGE_VALUE_HASHREF 
3703
3704 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3705 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3706 upbytes, downbytes, and totalbytes are appropriate keys.
3707
3708 All svc_accts which are part of this package have their values reset.
3709
3710 =cut
3711
3712 sub set_usage {
3713   my ($self, $valueref, %opt) = @_;
3714
3715   #only svc_acct can set_usage for now
3716   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3717     my $svc_x = $cust_svc->svc_x;
3718     $svc_x->set_usage($valueref, %opt)
3719       if $svc_x->can("set_usage");
3720   }
3721 }
3722
3723 =item recharge USAGE_VALUE_HASHREF 
3724
3725 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3726 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3727 upbytes, downbytes, and totalbytes are appropriate keys.
3728
3729 All svc_accts which are part of this package have their values incremented.
3730
3731 =cut
3732
3733 sub recharge {
3734   my ($self, $valueref) = @_;
3735
3736   #only svc_acct can set_usage for now
3737   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3738     my $svc_x = $cust_svc->svc_x;
3739     $svc_x->recharge($valueref)
3740       if $svc_x->can("recharge");
3741   }
3742 }
3743
3744 =item cust_pkg_discount
3745
3746 =cut
3747
3748 sub cust_pkg_discount {
3749   my $self = shift;
3750   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
3751 }
3752
3753 =item cust_pkg_discount_active
3754
3755 =cut
3756
3757 sub cust_pkg_discount_active {
3758   my $self = shift;
3759   grep { $_->status eq 'active' } $self->cust_pkg_discount;
3760 }
3761
3762 =item cust_pkg_usage
3763
3764 Returns a list of all voice usage counters attached to this package.
3765
3766 =cut
3767
3768 sub cust_pkg_usage {
3769   my $self = shift;
3770   qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
3771 }
3772
3773 =item apply_usage OPTIONS
3774
3775 Takes the following options:
3776 - cdr: a call detail record (L<FS::cdr>)
3777 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
3778 - minutes: the maximum number of minutes to be charged
3779
3780 Finds available usage minutes for a call of this class, and subtracts
3781 up to that many minutes from the usage pool.  If the usage pool is empty,
3782 and the C<cdr-minutes_priority> global config option is set, minutes may
3783 be taken from other calls as well.  Either way, an allocation record will
3784 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
3785 number of minutes of usage applied to the call.
3786
3787 =cut
3788
3789 sub apply_usage {
3790   my ($self, %opt) = @_;
3791   my $cdr = $opt{cdr};
3792   my $rate_detail = $opt{rate_detail};
3793   my $minutes = $opt{minutes};
3794   my $classnum = $rate_detail->classnum;
3795   my $pkgnum = $self->pkgnum;
3796   my $custnum = $self->custnum;
3797
3798   local $SIG{HUP} = 'IGNORE';
3799   local $SIG{INT} = 'IGNORE'; 
3800   local $SIG{QUIT} = 'IGNORE';
3801   local $SIG{TERM} = 'IGNORE';
3802   local $SIG{TSTP} = 'IGNORE'; 
3803   local $SIG{PIPE} = 'IGNORE'; 
3804
3805   my $oldAutoCommit = $FS::UID::AutoCommit;
3806   local $FS::UID::AutoCommit = 0;
3807   my $dbh = dbh;
3808   my $order = FS::Conf->new->config('cdr-minutes_priority');
3809
3810   my $is_classnum;
3811   if ( $classnum ) {
3812     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
3813   } else {
3814     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
3815   }
3816   my @usage_recs = qsearch({
3817       'table'     => 'cust_pkg_usage',
3818       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
3819                      ' JOIN cust_pkg             USING (pkgnum)'.
3820                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
3821       'select'    => 'cust_pkg_usage.*',
3822       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
3823                      " ( cust_pkg.custnum = $custnum AND ".
3824                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3825                      $is_classnum . ' AND '.
3826                      " cust_pkg_usage.minutes > 0",
3827       'order_by'  => " ORDER BY priority ASC",
3828   });
3829
3830   my $orig_minutes = $minutes;
3831   my $error;
3832   while (!$error and $minutes > 0 and @usage_recs) {
3833     my $cust_pkg_usage = shift @usage_recs;
3834     $cust_pkg_usage->select_for_update;
3835     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
3836         pkgusagenum => $cust_pkg_usage->pkgusagenum,
3837         acctid      => $cdr->acctid,
3838         minutes     => min($cust_pkg_usage->minutes, $minutes),
3839     });
3840     $cust_pkg_usage->set('minutes',
3841       sprintf('%.0f', $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes)
3842     );
3843     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
3844     $minutes -= $cdr_cust_pkg_usage->minutes;
3845   }
3846   if ( $order and $minutes > 0 and !$error ) {
3847     # then try to steal minutes from another call
3848     my %search = (
3849         'table'     => 'cdr_cust_pkg_usage',
3850         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
3851                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
3852                        ' JOIN cust_pkg              USING (pkgnum)'.
3853                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
3854                        ' JOIN cdr                   USING (acctid)',
3855         'select'    => 'cdr_cust_pkg_usage.*',
3856         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
3857                        " ( cust_pkg.pkgnum = $pkgnum OR ".
3858                        " ( cust_pkg.custnum = $custnum AND ".
3859                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
3860                        " part_pkg_usage_class.classnum = $classnum",
3861         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
3862     );
3863     if ( $order eq 'time' ) {
3864       # find CDRs that are using minutes, but have a later startdate
3865       # than this call
3866       my $startdate = $cdr->startdate;
3867       if ($startdate !~ /^\d+$/) {
3868         die "bad cdr startdate '$startdate'";
3869       }
3870       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
3871       # minimize needless reshuffling
3872       $search{'order_by'} .= ', cdr.startdate DESC';
3873     } else {
3874       # XXX may not work correctly with rate_time schedules.  Could 
3875       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
3876       # think...
3877       $search{'addl_from'} .=
3878         ' JOIN rate_detail'.
3879         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
3880       if ( $order eq 'rate_high' ) {
3881         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
3882                                 $rate_detail->min_charge;
3883         $search{'order_by'} .= ', rate_detail.min_charge ASC';
3884       } elsif ( $order eq 'rate_low' ) {
3885         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
3886                                 $rate_detail->min_charge;
3887         $search{'order_by'} .= ', rate_detail.min_charge DESC';
3888       } else {
3889         #  this should really never happen
3890         die "invalid cdr-minutes_priority value '$order'\n";
3891       }
3892     }
3893     my @cdr_usage_recs = qsearch(\%search);
3894     my %reproc_cdrs;
3895     while (!$error and @cdr_usage_recs and $minutes > 0) {
3896       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
3897       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
3898       my $old_cdr = $cdr_cust_pkg_usage->cdr;
3899       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
3900       $cdr_cust_pkg_usage->select_for_update;
3901       $old_cdr->select_for_update;
3902       $cust_pkg_usage->select_for_update;
3903       # in case someone else stole the usage from this CDR
3904       # while waiting for the lock...
3905       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
3906       # steal the usage allocation and flag the old CDR for reprocessing
3907       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
3908       # if the allocation is more minutes than we need, adjust it...
3909       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
3910       if ( $delta > 0 ) {
3911         $cdr_cust_pkg_usage->set('minutes', $minutes);
3912         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
3913         $error = $cust_pkg_usage->replace;
3914       }
3915       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
3916       $error ||= $cdr_cust_pkg_usage->replace;
3917       # deduct the stolen minutes
3918       $minutes -= $cdr_cust_pkg_usage->minutes;
3919     }
3920     # after all minute-stealing is done, reset the affected CDRs
3921     foreach (values %reproc_cdrs) {
3922       $error ||= $_->set_status('');
3923       # XXX or should we just call $cdr->rate right here?
3924       # it's not like we can create a loop this way, since the min_charge
3925       # or call time has to go monotonically in one direction.
3926       # we COULD get some very deep recursions going, though...
3927     }
3928   } # if $order and $minutes
3929   if ( $error ) {
3930     $dbh->rollback;
3931     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
3932   } else {
3933     $dbh->commit if $oldAutoCommit;
3934     return $orig_minutes - $minutes;
3935   }
3936 }
3937
3938 =item supplemental_pkgs
3939
3940 Returns a list of all packages supplemental to this one.
3941
3942 =cut
3943
3944 sub supplemental_pkgs {
3945   my $self = shift;
3946   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
3947 }
3948
3949 =item main_pkg
3950
3951 Returns the package that this one is supplemental to, if any.
3952
3953 =cut
3954
3955 sub main_pkg {
3956   my $self = shift;
3957   if ( $self->main_pkgnum ) {
3958     return FS::cust_pkg->by_key($self->main_pkgnum);
3959   }
3960   return;
3961 }
3962
3963 =back
3964
3965 =head1 CLASS METHODS
3966
3967 =over 4
3968
3969 =item recurring_sql
3970
3971 Returns an SQL expression identifying recurring packages.
3972
3973 =cut
3974
3975 sub recurring_sql { "
3976   '0' != ( select freq from part_pkg
3977              where cust_pkg.pkgpart = part_pkg.pkgpart )
3978 "; }
3979
3980 =item onetime_sql
3981
3982 Returns an SQL expression identifying one-time packages.
3983
3984 =cut
3985
3986 sub onetime_sql { "
3987   '0' = ( select freq from part_pkg
3988             where cust_pkg.pkgpart = part_pkg.pkgpart )
3989 "; }
3990
3991 =item ordered_sql
3992
3993 Returns an SQL expression identifying ordered packages (recurring packages not
3994 yet billed).
3995
3996 =cut
3997
3998 sub ordered_sql {
3999    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4000 }
4001
4002 =item active_sql
4003
4004 Returns an SQL expression identifying active packages.
4005
4006 =cut
4007
4008 sub active_sql {
4009   $_[0]->recurring_sql. "
4010   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4011   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4012   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4013 "; }
4014
4015 =item not_yet_billed_sql
4016
4017 Returns an SQL expression identifying packages which have not yet been billed.
4018
4019 =cut
4020
4021 sub not_yet_billed_sql { "
4022       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4023   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4024   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4025 "; }
4026
4027 =item inactive_sql
4028
4029 Returns an SQL expression identifying inactive packages (one-time packages
4030 that are otherwise unsuspended/uncancelled).
4031
4032 =cut
4033
4034 sub inactive_sql { "
4035   ". $_[0]->onetime_sql(). "
4036   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4037   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4038   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4039 "; }
4040
4041 =item susp_sql
4042 =item suspended_sql
4043
4044 Returns an SQL expression identifying suspended packages.
4045
4046 =cut
4047
4048 sub suspended_sql { susp_sql(@_); }
4049 sub susp_sql {
4050   #$_[0]->recurring_sql(). ' AND '.
4051   "
4052         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4053     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4054   ";
4055 }
4056
4057 =item cancel_sql
4058 =item cancelled_sql
4059
4060 Returns an SQL exprression identifying cancelled packages.
4061
4062 =cut
4063
4064 sub cancelled_sql { cancel_sql(@_); }
4065 sub cancel_sql { 
4066   #$_[0]->recurring_sql(). ' AND '.
4067   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4068 }
4069
4070 =item status_sql
4071
4072 Returns an SQL expression to give the package status as a string.
4073
4074 =cut
4075
4076 sub status_sql {
4077 "CASE
4078   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4079   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4080   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4081   WHEN ".onetime_sql()." THEN 'one-time charge'
4082   ELSE 'active'
4083 END"
4084 }
4085
4086 =item search HASHREF
4087
4088 (Class method)
4089
4090 Returns a qsearch hash expression to search for parameters specified in HASHREF.
4091 Valid parameters are
4092
4093 =over 4
4094
4095 =item agentnum
4096
4097 =item magic
4098
4099 active, inactive, suspended, cancel (or cancelled)
4100
4101 =item status
4102
4103 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
4104
4105 =item custom
4106
4107  boolean selects custom packages
4108
4109 =item classnum
4110
4111 =item pkgpart
4112
4113 pkgpart or arrayref or hashref of pkgparts
4114
4115 =item setup
4116
4117 arrayref of beginning and ending epoch date
4118
4119 =item last_bill
4120
4121 arrayref of beginning and ending epoch date
4122
4123 =item bill
4124
4125 arrayref of beginning and ending epoch date
4126
4127 =item adjourn
4128
4129 arrayref of beginning and ending epoch date
4130
4131 =item susp
4132
4133 arrayref of beginning and ending epoch date
4134
4135 =item expire
4136
4137 arrayref of beginning and ending epoch date
4138
4139 =item cancel
4140
4141 arrayref of beginning and ending epoch date
4142
4143 =item query
4144
4145 pkgnum or APKG_pkgnum
4146
4147 =item cust_fields
4148
4149 a value suited to passing to FS::UI::Web::cust_header
4150
4151 =item CurrentUser
4152
4153 specifies the user for agent virtualization
4154
4155 =item fcc_line
4156
4157 boolean; if true, returns only packages with more than 0 FCC phone lines.
4158
4159 =item state, country
4160
4161 Limit to packages with a service location in the specified state and country.
4162 For FCC 477 reporting, mostly.
4163
4164 =back
4165
4166 =cut
4167
4168 sub search {
4169   my ($class, $params) = @_;
4170   my @where = ();
4171
4172   ##
4173   # parse agent
4174   ##
4175
4176   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
4177     push @where,
4178       "cust_main.agentnum = $1";
4179   }
4180
4181   ##
4182   # parse customer sales person
4183   ##
4184
4185   if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
4186     push @where, ($1 > 0) ? "cust_main.salesnum = $1"
4187                           : 'cust_main.salesnum IS NULL';
4188   }
4189
4190
4191   ##
4192   # parse sales person
4193   ##
4194
4195   if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
4196     push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
4197                           : 'cust_pkg.salesnum IS NULL';
4198   }
4199
4200   ##
4201   # parse custnum
4202   ##
4203
4204   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
4205     push @where,
4206       "cust_pkg.custnum = $1";
4207   }
4208
4209   ##
4210   # custbatch
4211   ##
4212
4213   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
4214     push @where,
4215       "cust_pkg.pkgbatch = '$1'";
4216   }
4217
4218   ##
4219   # parse status
4220   ##
4221
4222   if (    $params->{'magic'}  eq 'active'
4223        || $params->{'status'} eq 'active' ) {
4224
4225     push @where, FS::cust_pkg->active_sql();
4226
4227   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
4228             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
4229
4230     push @where, FS::cust_pkg->not_yet_billed_sql();
4231
4232   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
4233             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
4234
4235     push @where, FS::cust_pkg->inactive_sql();
4236
4237   } elsif (    $params->{'magic'}  eq 'suspended'
4238             || $params->{'status'} eq 'suspended'  ) {
4239
4240     push @where, FS::cust_pkg->suspended_sql();
4241
4242   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
4243             || $params->{'status'} =~ /^cancell?ed$/ ) {
4244
4245     push @where, FS::cust_pkg->cancelled_sql();
4246
4247   }
4248
4249   ###
4250   # parse package class
4251   ###
4252
4253   if ( exists($params->{'classnum'}) ) {
4254
4255     my @classnum = ();
4256     if ( ref($params->{'classnum'}) ) {
4257
4258       if ( ref($params->{'classnum'}) eq 'HASH' ) {
4259         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
4260       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
4261         @classnum = @{ $params->{'classnum'} };
4262       } else {
4263         die 'unhandled classnum ref '. $params->{'classnum'};
4264       }
4265
4266
4267     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
4268       @classnum = ( $1 );
4269     }
4270
4271     if ( @classnum ) {
4272
4273       my @c_where = ();
4274       my @nums = grep $_, @classnum;
4275       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
4276       my $null = scalar( grep { $_ eq '' } @classnum );
4277       push @c_where, 'part_pkg.classnum IS NULL' if $null;
4278
4279       if ( scalar(@c_where) == 1 ) {
4280         push @where, @c_where;
4281       } elsif ( @c_where ) {
4282         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
4283       }
4284
4285     }
4286     
4287
4288   }
4289
4290   ###
4291   # parse package report options
4292   ###
4293
4294   my @report_option = ();
4295   if ( exists($params->{'report_option'}) ) {
4296     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
4297       @report_option = @{ $params->{'report_option'} };
4298     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
4299       @report_option = split(',', $1);
4300     }
4301
4302   }
4303
4304   if (@report_option) {
4305     # this will result in the empty set for the dangling comma case as it should
4306     push @where, 
4307       map{ "0 < ( SELECT count(*) FROM part_pkg_option
4308                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4309                     AND optionname = 'report_option_$_'
4310                     AND optionvalue = '1' )"
4311          } @report_option;
4312   }
4313
4314   foreach my $any ( grep /^report_option_any/, keys %$params ) {
4315
4316     my @report_option_any = ();
4317     if ( ref($params->{$any}) eq 'ARRAY' ) {
4318       @report_option_any = @{ $params->{$any} };
4319     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
4320       @report_option_any = split(',', $1);
4321     }
4322
4323     if (@report_option_any) {
4324       # this will result in the empty set for the dangling comma case as it should
4325       push @where, ' ( '. join(' OR ',
4326         map{ "0 < ( SELECT count(*) FROM part_pkg_option
4327                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4328                       AND optionname = 'report_option_$_'
4329                       AND optionvalue = '1' )"
4330            } @report_option_any
4331       ). ' ) ';
4332     }
4333
4334   }
4335
4336   ###
4337   # parse custom
4338   ###
4339
4340   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
4341
4342   ###
4343   # parse fcc_line
4344   ###
4345
4346   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
4347                                                         if $params->{fcc_line};
4348
4349   ###
4350   # parse censustract
4351   ###
4352
4353   if ( exists($params->{'censustract'}) ) {
4354     $params->{'censustract'} =~ /^([.\d]*)$/;
4355     my $censustract = "cust_location.censustract = '$1'";
4356     $censustract .= ' OR cust_location.censustract is NULL' unless $1;
4357     push @where,  "( $censustract )";
4358   }
4359
4360   ###
4361   # parse censustract2
4362   ###
4363   if ( exists($params->{'censustract2'})
4364        && $params->{'censustract2'} =~ /^(\d*)$/
4365      )
4366   {
4367     if ($1) {
4368       push @where, "cust_location.censustract LIKE '$1%'";
4369     } else {
4370       push @where,
4371         "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
4372     }
4373   }
4374
4375   ###
4376   # parse country/state
4377   ###
4378   for (qw(state country)) { # parsing rules are the same for these
4379   if ( exists($params->{$_}) 
4380     && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
4381     {
4382       # XXX post-2.3 only--before that, state/country may be in cust_main
4383       push @where, "cust_location.$_ = '$1'";
4384     }
4385   }
4386
4387   ###
4388   # parse part_pkg
4389   ###
4390
4391   if ( ref($params->{'pkgpart'}) ) {
4392
4393     my @pkgpart = ();
4394     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
4395       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
4396     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
4397       @pkgpart = @{ $params->{'pkgpart'} };
4398     } else {
4399       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
4400     }
4401
4402     @pkgpart = grep /^(\d+)$/, @pkgpart;
4403
4404     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
4405
4406   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4407     push @where, "pkgpart = $1";
4408   } 
4409
4410   ###
4411   # parse dates
4412   ###
4413
4414   my $orderby = '';
4415
4416   #false laziness w/report_cust_pkg.html
4417   my %disable = (
4418     'all'             => {},
4419     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
4420     'active'          => { 'susp'=>1, 'cancel'=>1 },
4421     'suspended'       => { 'cancel' => 1 },
4422     'cancelled'       => {},
4423     ''                => {},
4424   );
4425
4426   if( exists($params->{'active'} ) ) {
4427     # This overrides all the other date-related fields
4428     my($beginning, $ending) = @{$params->{'active'}};
4429     push @where,
4430       "cust_pkg.setup IS NOT NULL",
4431       "cust_pkg.setup <= $ending",
4432       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
4433       "NOT (".FS::cust_pkg->onetime_sql . ")";
4434   }
4435   else {
4436     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
4437
4438       next unless exists($params->{$field});
4439
4440       my($beginning, $ending) = @{$params->{$field}};
4441
4442       next if $beginning == 0 && $ending == 4294967295;
4443
4444       push @where,
4445         "cust_pkg.$field IS NOT NULL",
4446         "cust_pkg.$field >= $beginning",
4447         "cust_pkg.$field <= $ending";
4448
4449       $orderby ||= "ORDER BY cust_pkg.$field";
4450
4451     }
4452   }
4453
4454   $orderby ||= 'ORDER BY bill';
4455
4456   ###
4457   # parse magic, legacy, etc.
4458   ###
4459
4460   if ( $params->{'magic'} &&
4461        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
4462   ) {
4463
4464     $orderby = 'ORDER BY pkgnum';
4465
4466     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
4467       push @where, "pkgpart = $1";
4468     }
4469
4470   } elsif ( $params->{'query'} eq 'pkgnum' ) {
4471
4472     $orderby = 'ORDER BY pkgnum';
4473
4474   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
4475
4476     $orderby = 'ORDER BY pkgnum';
4477
4478     push @where, '0 < (
4479       SELECT count(*) FROM pkg_svc
4480        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
4481          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
4482                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
4483                                      AND cust_svc.svcpart = pkg_svc.svcpart
4484                                 )
4485     )';
4486   
4487   }
4488
4489   ##
4490   # setup queries, links, subs, etc. for the search
4491   ##
4492
4493   # here is the agent virtualization
4494   if ($params->{CurrentUser}) {
4495     my $access_user =
4496       qsearchs('access_user', { username => $params->{CurrentUser} });
4497
4498     if ($access_user) {
4499       push @where, $access_user->agentnums_sql('table'=>'cust_main');
4500     } else {
4501       push @where, "1=0";
4502     }
4503   } else {
4504     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
4505   }
4506
4507   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
4508
4509   my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
4510                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
4511                   'LEFT JOIN cust_location USING ( locationnum ) '.
4512                   FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
4513
4514   my $select;
4515   my $count_query;
4516   if ( $params->{'select_zip5'} ) {
4517     my $zip = 'cust_location.zip';
4518
4519     $select = "DISTINCT substr($zip,1,5) as zip";
4520     $orderby = "ORDER BY substr($zip,1,5)";
4521     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
4522   } else {
4523     $select = join(', ',
4524                          'cust_pkg.*',
4525                          ( map "part_pkg.$_", qw( pkg freq ) ),
4526                          'pkg_class.classname',
4527                          'cust_main.custnum AS cust_main_custnum',
4528                          FS::UI::Web::cust_sql_fields(
4529                            $params->{'cust_fields'}
4530                          ),
4531                   );
4532     $count_query = 'SELECT COUNT(*)';
4533   }
4534
4535   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
4536
4537   my $sql_query = {
4538     'table'       => 'cust_pkg',
4539     'hashref'     => {},
4540     'select'      => $select,
4541     'extra_sql'   => $extra_sql,
4542     'order_by'    => $orderby,
4543     'addl_from'   => $addl_from,
4544     'count_query' => $count_query,
4545   };
4546
4547 }
4548
4549 =item fcc_477_count
4550
4551 Returns a list of two package counts.  The first is a count of packages
4552 based on the supplied criteria and the second is the count of residential
4553 packages with those same criteria.  Criteria are specified as in the search
4554 method.
4555
4556 =cut
4557
4558 sub fcc_477_count {
4559   my ($class, $params) = @_;
4560
4561   my $sql_query = $class->search( $params );
4562
4563   my $count_sql = delete($sql_query->{'count_query'});
4564   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4565     or die "couldn't parse count_sql";
4566
4567   my $count_sth = dbh->prepare($count_sql)
4568     or die "Error preparing $count_sql: ". dbh->errstr;
4569   $count_sth->execute
4570     or die "Error executing $count_sql: ". $count_sth->errstr;
4571   my $count_arrayref = $count_sth->fetchrow_arrayref;
4572
4573   return ( @$count_arrayref );
4574
4575 }
4576
4577 =item tax_locationnum_sql
4578
4579 Returns an SQL expression for the tax location for a package, based
4580 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4581
4582 =cut
4583
4584 sub tax_locationnum_sql {
4585   my $conf = FS::Conf->new;
4586   if ( $conf->exists('tax-pkg_address') ) {
4587     'cust_pkg.locationnum';
4588   }
4589   elsif ( $conf->exists('tax-ship_address') ) {
4590     'cust_main.ship_locationnum';
4591   }
4592   else {
4593     'cust_main.bill_locationnum';
4594   }
4595 }
4596
4597 =item location_sql
4598
4599 Returns a list: the first item is an SQL fragment identifying matching 
4600 packages/customers via location (taking into account shipping and package
4601 address taxation, if enabled), and subsequent items are the parameters to
4602 substitute for the placeholders in that fragment.
4603
4604 =cut
4605
4606 sub location_sql {
4607   my($class, %opt) = @_;
4608   my $ornull = $opt{'ornull'};
4609
4610   my $conf = new FS::Conf;
4611
4612   # '?' placeholders in _location_sql_where
4613   my $x = $ornull ? 3 : 2;
4614   my @bill_param = ( 
4615     ('district')x3,
4616     ('city')x3, 
4617     ('county')x$x,
4618     ('state')x$x,
4619     'country'
4620   );
4621
4622   my $main_where;
4623   my @main_param;
4624   if ( $conf->exists('tax-ship_address') ) {
4625
4626     $main_where = "(
4627          (     ( ship_last IS NULL     OR  ship_last  = '' )
4628            AND ". _location_sql_where('cust_main', '', $ornull ). "
4629          )
4630       OR (       ship_last IS NOT NULL AND ship_last != ''
4631            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4632          )
4633     )";
4634     #    AND payby != 'COMP'
4635
4636     @main_param = ( @bill_param, @bill_param );
4637
4638   } else {
4639
4640     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4641     @main_param = @bill_param;
4642
4643   }
4644
4645   my $where;
4646   my @param;
4647   if ( $conf->exists('tax-pkg_address') ) {
4648
4649     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4650
4651     $where = " (
4652                     ( cust_pkg.locationnum IS     NULL AND $main_where )
4653                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
4654                )
4655              ";
4656     @param = ( @main_param, @bill_param );
4657   
4658   } else {
4659
4660     $where = $main_where;
4661     @param = @main_param;
4662
4663   }
4664
4665   ( $where, @param );
4666
4667 }
4668
4669 #subroutine, helper for location_sql
4670 sub _location_sql_where {
4671   my $table  = shift;
4672   my $prefix = @_ ? shift : '';
4673   my $ornull = @_ ? shift : '';
4674
4675 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4676
4677   $ornull = $ornull ? ' OR ? IS NULL ' : '';
4678
4679   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
4680   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
4681   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
4682
4683   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4684
4685 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
4686   "
4687         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4688     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4689     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
4690     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
4691     AND   $table.${prefix}country  = ?
4692   ";
4693 }
4694
4695 sub _X_show_zero {
4696   my( $self, $what ) = @_;
4697
4698   my $what_show_zero = $what. '_show_zero';
4699   length($self->$what_show_zero())
4700     ? ($self->$what_show_zero() eq 'Y')
4701     : $self->part_pkg->$what_show_zero();
4702 }
4703
4704 =head1 SUBROUTINES
4705
4706 =over 4
4707
4708 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
4709
4710 CUSTNUM is a customer (see L<FS::cust_main>)
4711
4712 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4713 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
4714 permitted.
4715
4716 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
4717 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
4718 new billing items.  An error is returned if this is not possible (see
4719 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
4720 parameter.
4721
4722 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4723 newly-created cust_pkg objects.
4724
4725 REFNUM, if specified, will specify the FS::pkg_referral record to be created
4726 and inserted.  Multiple FS::pkg_referral records can be created by
4727 setting I<refnum> to an array reference of refnums or a hash reference with
4728 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
4729 record will be created corresponding to cust_main.refnum.
4730
4731 =cut
4732
4733 sub order {
4734   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
4735
4736   my $conf = new FS::Conf;
4737
4738   # Transactionize this whole mess
4739   local $SIG{HUP} = 'IGNORE';
4740   local $SIG{INT} = 'IGNORE'; 
4741   local $SIG{QUIT} = 'IGNORE';
4742   local $SIG{TERM} = 'IGNORE';
4743   local $SIG{TSTP} = 'IGNORE'; 
4744   local $SIG{PIPE} = 'IGNORE'; 
4745
4746   my $oldAutoCommit = $FS::UID::AutoCommit;
4747   local $FS::UID::AutoCommit = 0;
4748   my $dbh = dbh;
4749
4750   my $error;
4751 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
4752 #  return "Customer not found: $custnum" unless $cust_main;
4753
4754   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
4755     if $DEBUG;
4756
4757   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4758                          @$remove_pkgnum;
4759
4760   my $change = scalar(@old_cust_pkg) != 0;
4761
4762   my %hash = (); 
4763   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
4764
4765     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
4766          " to pkgpart ". $pkgparts->[0]. "\n"
4767       if $DEBUG;
4768
4769     my $err_or_cust_pkg =
4770       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
4771                                 'refnum'  => $refnum,
4772                               );
4773
4774     unless (ref($err_or_cust_pkg)) {
4775       $dbh->rollback if $oldAutoCommit;
4776       return $err_or_cust_pkg;
4777     }
4778
4779     push @$return_cust_pkg, $err_or_cust_pkg;
4780     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4781     return '';
4782
4783   }
4784
4785   # Create the new packages.
4786   foreach my $pkgpart (@$pkgparts) {
4787
4788     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
4789
4790     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
4791                                       pkgpart => $pkgpart,
4792                                       refnum  => $refnum,
4793                                       %hash,
4794                                     };
4795     $error = $cust_pkg->insert( 'change' => $change );
4796     push @$return_cust_pkg, $cust_pkg;
4797
4798     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
4799       my $supp_pkg = FS::cust_pkg->new({
4800           custnum => $custnum,
4801           pkgpart => $link->dst_pkgpart,
4802           refnum  => $refnum,
4803           main_pkgnum => $cust_pkg->pkgnum,
4804           %hash,
4805       });
4806       $error ||= $supp_pkg->insert( 'change' => $change );
4807       push @$return_cust_pkg, $supp_pkg;
4808     }
4809
4810     if ($error) {
4811       $dbh->rollback if $oldAutoCommit;
4812       return $error;
4813     }
4814
4815   }
4816   # $return_cust_pkg now contains refs to all of the newly 
4817   # created packages.
4818
4819   # Transfer services and cancel old packages.
4820   foreach my $old_pkg (@old_cust_pkg) {
4821
4822     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
4823       if $DEBUG;
4824
4825     foreach my $new_pkg (@$return_cust_pkg) {
4826       $error = $old_pkg->transfer($new_pkg);
4827       if ($error and $error == 0) {
4828         # $old_pkg->transfer failed.
4829         $dbh->rollback if $oldAutoCommit;
4830         return $error;
4831       }
4832     }
4833
4834     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
4835       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
4836       foreach my $new_pkg (@$return_cust_pkg) {
4837         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
4838         if ($error and $error == 0) {
4839           # $old_pkg->transfer failed.
4840         $dbh->rollback if $oldAutoCommit;
4841         return $error;
4842         }
4843       }
4844     }
4845
4846     if ($error > 0) {
4847       # Transfers were successful, but we went through all of the 
4848       # new packages and still had services left on the old package.
4849       # We can't cancel the package under the circumstances, so abort.
4850       $dbh->rollback if $oldAutoCommit;
4851       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
4852     }
4853     $error = $old_pkg->cancel( quiet=>1 );
4854     if ($error) {
4855       $dbh->rollback;
4856       return $error;
4857     }
4858   }
4859   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4860   '';
4861 }
4862
4863 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
4864
4865 A bulk change method to change packages for multiple customers.
4866
4867 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4868 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
4869 permitted.
4870
4871 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
4872 replace.  The services (see L<FS::cust_svc>) are moved to the
4873 new billing items.  An error is returned if this is not possible (see
4874 L<FS::pkg_svc>).
4875
4876 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4877 newly-created cust_pkg objects.
4878
4879 =cut
4880
4881 sub bulk_change {
4882   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
4883
4884   # Transactionize this whole mess
4885   local $SIG{HUP} = 'IGNORE';
4886   local $SIG{INT} = 'IGNORE'; 
4887   local $SIG{QUIT} = 'IGNORE';
4888   local $SIG{TERM} = 'IGNORE';
4889   local $SIG{TSTP} = 'IGNORE'; 
4890   local $SIG{PIPE} = 'IGNORE'; 
4891
4892   my $oldAutoCommit = $FS::UID::AutoCommit;
4893   local $FS::UID::AutoCommit = 0;
4894   my $dbh = dbh;
4895
4896   my @errors;
4897   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4898                          @$remove_pkgnum;
4899
4900   while(scalar(@old_cust_pkg)) {
4901     my @return = ();
4902     my $custnum = $old_cust_pkg[0]->custnum;
4903     my (@remove) = map { $_->pkgnum }
4904                    grep { $_->custnum == $custnum } @old_cust_pkg;
4905     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
4906
4907     my $error = order $custnum, $pkgparts, \@remove, \@return;
4908
4909     push @errors, $error
4910       if $error;
4911     push @$return_cust_pkg, @return;
4912   }
4913
4914   if (scalar(@errors)) {
4915     $dbh->rollback if $oldAutoCommit;
4916     return join(' / ', @errors);
4917   }
4918
4919   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4920   '';
4921 }
4922
4923 # Used by FS::Upgrade to migrate to a new database.
4924 sub _upgrade_data {  # class method
4925   my ($class, %opts) = @_;
4926   $class->_upgrade_otaker(%opts);
4927   my @statements = (
4928     # RT#10139, bug resulting in contract_end being set when it shouldn't
4929   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
4930     # RT#10830, bad calculation of prorate date near end of year
4931     # the date range for bill is December 2009, and we move it forward
4932     # one year if it's before the previous bill date (which it should 
4933     # never be)
4934   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
4935   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
4936   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
4937     # RT6628, add order_date to cust_pkg
4938     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
4939         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
4940         history_action = \'insert\') where order_date is null',
4941   );
4942   foreach my $sql (@statements) {
4943     my $sth = dbh->prepare($sql);
4944     $sth->execute or die $sth->errstr;
4945   }
4946 }
4947
4948 =back
4949
4950 =head1 BUGS
4951
4952 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
4953
4954 In sub order, the @pkgparts array (passed by reference) is clobbered.
4955
4956 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
4957 method to pass dates to the recur_prog expression, it should do so.
4958
4959 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
4960 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
4961 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
4962 configuration values.  Probably need a subroutine which decides what to do
4963 based on whether or not we've fetched the user yet, rather than a hash.  See
4964 FS::UID and the TODO.
4965
4966 Now that things are transactional should the check in the insert method be
4967 moved to check ?
4968
4969 =head1 SEE ALSO
4970
4971 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
4972 L<FS::pkg_svc>, schema.html from the base documentation
4973
4974 =cut
4975
4976 1;
4977