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