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