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