8153bcc2282cfa64d9a1aba3b263e7e4b2ea3ee3
[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   if ( $opt->{'cust_location'} ) {
2017     $error = $opt->{'cust_location'}->find_or_insert;
2018     if ( $error ) {
2019       $dbh->rollback if $oldAutoCommit;
2020       return "creating location record: $error";
2021     }
2022     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
2023   }
2024
2025   # Before going any further here: if the package is still in the pre-setup
2026   # state, it's safe to modify it in place. No need to charge/credit for 
2027   # partial period, transfer services, transfer usage pools, copy invoice
2028   # details, or change any dates.
2029   if ( ! $self->setup and ! $opt->{cust_pkg} and ! $opt->{cust_main} ) {
2030     foreach ( qw( locationnum pkgpart quantity refnum salesnum ) ) {
2031       if ( length($opt->{$_}) ) {
2032         $self->set($_, $opt->{$_});
2033       }
2034     }
2035     # almost. if the new pkgpart specifies start/adjourn/expire timers, 
2036     # apply those.
2037     if ( $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart ) {
2038       $self->set_initial_timers;
2039     }
2040     $error = $self->replace;
2041     if ( $error ) {
2042       $dbh->rollback if $oldAutoCommit;
2043       return "modifying package: $error";
2044     } else {
2045       $dbh->commit if $oldAutoCommit;
2046       return '';
2047     }
2048   }
2049
2050   my %hash = (); 
2051
2052   my $time = time;
2053
2054   $hash{'setup'} = $time if $self->setup;
2055
2056   $hash{'change_date'} = $time;
2057   $hash{"change_$_"}  = $self->$_()
2058     foreach qw( pkgnum pkgpart locationnum );
2059
2060   if ( $opt->{'cust_pkg'} ) {
2061     # treat changing to a package with a different pkgpart as a 
2062     # pkgpart change (because it is)
2063     $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart;
2064   }
2065
2066   # whether to override pkgpart checking on the new package
2067   my $same_pkgpart = 1;
2068   if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
2069     $same_pkgpart = 0;
2070   }
2071
2072   my $unused_credit = 0;
2073   my $keep_dates = $opt->{'keep_dates'};
2074   # Special case.  If the pkgpart is changing, and the customer is
2075   # going to be credited for remaining time, don't keep setup, bill, 
2076   # or last_bill dates, and DO pass the flag to cancel() to credit 
2077   # the customer.
2078   if ( $opt->{'pkgpart'} 
2079        and $opt->{'pkgpart'} != $self->pkgpart
2080        and $self->part_pkg->option('unused_credit_change', 1) ) {
2081     $unused_credit = 1;
2082     $keep_dates = 0;
2083     $hash{$_} = '' foreach qw(setup bill last_bill);
2084   }
2085
2086   if ( $keep_dates ) {
2087     foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
2088                           resume start_date contract_end ) ) {
2089       $hash{$date} = $self->getfield($date);
2090     }
2091   }
2092   # always keep this date, regardless of anything
2093   # (the date of the package change is in a different field)
2094   $hash{'order_date'} = $self->getfield('order_date');
2095
2096   # allow $opt->{'locationnum'} = '' to specifically set it to null
2097   # (i.e. customer default location)
2098   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2099
2100   # usually this doesn't matter.  the two cases where it does are:
2101   # 1. unused_credit_change + pkgpart change + setup fee on the new package
2102   # and
2103   # 2. (more importantly) changing a package before it's billed
2104   $hash{'waive_setup'} = $self->waive_setup;
2105
2106   # if this package is scheduled for a future package change, preserve that
2107   $hash{'change_to_pkgnum'} = $self->change_to_pkgnum;
2108
2109   my $custnum = $self->custnum;
2110   if ( $opt->{cust_main} ) {
2111     my $cust_main = $opt->{cust_main};
2112     unless ( $cust_main->custnum ) { 
2113       my $error = $cust_main->insert( @{ $opt->{cust_main_insert_args}||[] } );
2114       if ( $error ) {
2115         $dbh->rollback if $oldAutoCommit;
2116         return "inserting customer record: $error";
2117       }
2118     }
2119     $custnum = $cust_main->custnum;
2120   }
2121
2122   $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
2123
2124   my $cust_pkg;
2125   if ( $opt->{'cust_pkg'} ) {
2126     # The target package already exists; update it to show that it was 
2127     # changed from this package.
2128     $cust_pkg = $opt->{'cust_pkg'};
2129
2130     foreach ( qw( pkgnum pkgpart locationnum ) ) {
2131       $cust_pkg->set("change_$_", $self->get($_));
2132     }
2133     $cust_pkg->set('change_date', $time);
2134     $error = $cust_pkg->replace;
2135
2136   } else {
2137     # Create the new package.
2138     $cust_pkg = new FS::cust_pkg {
2139       custnum     => $custnum,
2140       locationnum => $opt->{'locationnum'},
2141       ( map {  $_ => ( $opt->{$_} || $self->$_() )  }
2142           qw( pkgpart quantity refnum salesnum )
2143       ),
2144       %hash,
2145     };
2146     $error = $cust_pkg->insert( 'change' => 1,
2147                                 'allow_pkgpart' => $same_pkgpart );
2148   }
2149   if ($error) {
2150     $dbh->rollback if $oldAutoCommit;
2151     return "inserting new package: $error";
2152   }
2153
2154   # Transfer services and cancel old package.
2155   # Enforce service limits only if this is a pkgpart change.
2156   local $FS::cust_svc::ignore_quantity;
2157   $FS::cust_svc::ignore_quantity = 1 if $same_pkgpart;
2158   $error = $self->transfer($cust_pkg);
2159   if ($error and $error == 0) {
2160     # $old_pkg->transfer failed.
2161     $dbh->rollback if $oldAutoCommit;
2162     return "transferring $error";
2163   }
2164
2165   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2166     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2167     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
2168     if ($error and $error == 0) {
2169       # $old_pkg->transfer failed.
2170       $dbh->rollback if $oldAutoCommit;
2171       return "converting $error";
2172     }
2173   }
2174
2175   # We set unprotect_svcs when executing a "future package change".  It's 
2176   # not a user-interactive operation, so returning an error means the 
2177   # package change will just fail.  Rather than have that happen, we'll 
2178   # let leftover services be deleted.
2179   if ($error > 0 and !$opt->{'unprotect_svcs'}) {
2180     # Transfers were successful, but we still had services left on the old
2181     # package.  We can't change the package under this circumstances, so abort.
2182     $dbh->rollback if $oldAutoCommit;
2183     return "unable to transfer all services";
2184   }
2185
2186   #reset usage if changing pkgpart
2187   # AND usage rollover is off (otherwise adds twice, now and at package bill)
2188   if ($self->pkgpart != $cust_pkg->pkgpart) {
2189     my $part_pkg = $cust_pkg->part_pkg;
2190     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
2191                                                  ? ()
2192                                                  : ( 'null' => 1 )
2193                                    )
2194       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
2195
2196     if ($error) {
2197       $dbh->rollback if $oldAutoCommit;
2198       return "setting usage values: $error";
2199     }
2200   } else {
2201     # if NOT changing pkgpart, transfer any usage pools over
2202     foreach my $usage ($self->cust_pkg_usage) {
2203       $usage->set('pkgnum', $cust_pkg->pkgnum);
2204       $error = $usage->replace;
2205       if ( $error ) {
2206         $dbh->rollback if $oldAutoCommit;
2207         return "transferring usage pools: $error";
2208       }
2209     }
2210   }
2211
2212   # transfer discounts, if we're not changing pkgpart
2213   if ( $same_pkgpart ) {
2214     foreach my $old_discount ($self->cust_pkg_discount_active) {
2215       # don't remove the old discount, we may still need to bill that package.
2216       my $new_discount = new FS::cust_pkg_discount {
2217         'pkgnum'      => $cust_pkg->pkgnum,
2218         'discountnum' => $old_discount->discountnum,
2219         'months_used' => $old_discount->months_used,
2220       };
2221       $error = $new_discount->insert;
2222       if ( $error ) {
2223         $dbh->rollback if $oldAutoCommit;
2224         return "transferring discounts: $error";
2225       }
2226     }
2227   }
2228
2229   # transfer (copy) invoice details
2230   foreach my $detail ($self->cust_pkg_detail) {
2231     my $new_detail = FS::cust_pkg_detail->new({ $detail->hash });
2232     $new_detail->set('pkgdetailnum', '');
2233     $new_detail->set('pkgnum', $cust_pkg->pkgnum);
2234     $error = $new_detail->insert;
2235     if ( $error ) {
2236       $dbh->rollback if $oldAutoCommit;
2237       return "transferring package notes: $error";
2238     }
2239   }
2240   
2241   my @new_supp_pkgs;
2242
2243   if ( !$opt->{'cust_pkg'} ) {
2244     # Order any supplemental packages.
2245     my $part_pkg = $cust_pkg->part_pkg;
2246     my @old_supp_pkgs = $self->supplemental_pkgs;
2247     foreach my $link ($part_pkg->supp_part_pkg_link) {
2248       my $old;
2249       foreach (@old_supp_pkgs) {
2250         if ($_->pkgpart == $link->dst_pkgpart) {
2251           $old = $_;
2252           $_->pkgpart(0); # so that it can't match more than once
2253         }
2254         last if $old;
2255       }
2256       # false laziness with FS::cust_main::Packages::order_pkg
2257       my $new = FS::cust_pkg->new({
2258           pkgpart       => $link->dst_pkgpart,
2259           pkglinknum    => $link->pkglinknum,
2260           custnum       => $custnum,
2261           main_pkgnum   => $cust_pkg->pkgnum,
2262           locationnum   => $cust_pkg->locationnum,
2263           start_date    => $cust_pkg->start_date,
2264           order_date    => $cust_pkg->order_date,
2265           expire        => $cust_pkg->expire,
2266           adjourn       => $cust_pkg->adjourn,
2267           contract_end  => $cust_pkg->contract_end,
2268           refnum        => $cust_pkg->refnum,
2269           discountnum   => $cust_pkg->discountnum,
2270           waive_setup   => $cust_pkg->waive_setup,
2271       });
2272       if ( $old and $opt->{'keep_dates'} ) {
2273         foreach (qw(setup bill last_bill)) {
2274           $new->set($_, $old->get($_));
2275         }
2276       }
2277       $error = $new->insert( allow_pkgpart => $same_pkgpart );
2278       # transfer services
2279       if ( $old ) {
2280         $error ||= $old->transfer($new);
2281       }
2282       if ( $error and $error > 0 ) {
2283         # no reason why this should ever fail, but still...
2284         $error = "Unable to transfer all services from supplemental package ".
2285           $old->pkgnum;
2286       }
2287       if ( $error ) {
2288         $dbh->rollback if $oldAutoCommit;
2289         return $error;
2290       }
2291       push @new_supp_pkgs, $new;
2292     }
2293   } # if !$opt->{'cust_pkg'}
2294     # because if there is one, then supplemental packages would already
2295     # have been created for it.
2296
2297   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
2298   #remaining time.
2299   #Don't allow billing the package (preceding period packages and/or 
2300   #outstanding usage) if we are keeping dates (i.e. location changing), 
2301   #because the new package will be billed for the same date range.
2302   #Supplemental packages are also canceled here.
2303
2304   # during scheduled changes, avoid canceling the package we just
2305   # changed to (duh)
2306   $self->set('change_to_pkgnum' => '');
2307
2308   $error = $self->cancel(
2309     quiet          => 1, 
2310     unused_credit  => $unused_credit,
2311     nobill         => $keep_dates,
2312     change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
2313     no_delay_cancel => 1,
2314   );
2315   if ($error) {
2316     $dbh->rollback if $oldAutoCommit;
2317     return "canceling old package: $error";
2318   }
2319
2320   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
2321     #$self->cust_main
2322     my $error = $cust_pkg->cust_main->bill( 
2323       'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
2324     );
2325     if ( $error ) {
2326       $dbh->rollback if $oldAutoCommit;
2327       return "billing new package: $error";
2328     }
2329   }
2330
2331   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2332
2333   $cust_pkg;
2334
2335 }
2336
2337 =item change_later OPTION => VALUE...
2338
2339 Schedule a package change for a later date.  This actually orders the new
2340 package immediately, but sets its start date for a future date, and sets
2341 the current package to expire on the same date.
2342
2343 If the package is already scheduled for a change, this can be called with 
2344 'start_date' to change the scheduled date, or with pkgpart and/or 
2345 locationnum to modify the package change.  To cancel the scheduled change 
2346 entirely, see C<abort_change>.
2347
2348 Options include:
2349
2350 =over 4
2351
2352 =item start_date
2353
2354 The date for the package change.  Required, and must be in the future.
2355
2356 =item pkgpart
2357
2358 =item locationnum
2359
2360 =item quantity
2361
2362 The pkgpart. locationnum, and quantity of the new package, with the same 
2363 meaning as in C<change>.
2364
2365 =back
2366
2367 =cut
2368
2369 sub change_later {
2370   my $self = shift;
2371   my $opt = ref($_[0]) ? shift : { @_ };
2372
2373   my $oldAutoCommit = $FS::UID::AutoCommit;
2374   local $FS::UID::AutoCommit = 0;
2375   my $dbh = dbh;
2376
2377   my $cust_main = $self->cust_main;
2378
2379   my $date = delete $opt->{'start_date'} or return 'start_date required';
2380  
2381   if ( $date <= time ) {
2382     $dbh->rollback if $oldAutoCommit;
2383     return "start_date $date is in the past";
2384   }
2385
2386   my $error;
2387
2388   if ( $self->change_to_pkgnum ) {
2389     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
2390     my $new_pkgpart = $opt->{'pkgpart'}
2391         if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart;
2392     my $new_locationnum = $opt->{'locationnum'}
2393         if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum;
2394     my $new_quantity = $opt->{'quantity'}
2395         if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity;
2396     if ( $new_pkgpart or $new_locationnum or $new_quantity ) {
2397       # it hasn't been billed yet, so in principle we could just edit
2398       # it in place (w/o a package change), but that's bad form.
2399       # So change the package according to the new options...
2400       my $err_or_pkg = $change_to->change(%$opt);
2401       if ( ref $err_or_pkg ) {
2402         # Then set that package up for a future start.
2403         $self->set('change_to_pkgnum', $err_or_pkg->pkgnum);
2404         $self->set('expire', $date); # in case it's different
2405         $err_or_pkg->set('start_date', $date);
2406         $err_or_pkg->set('change_date', '');
2407         $err_or_pkg->set('change_pkgnum', '');
2408
2409         $error = $self->replace       ||
2410                  $err_or_pkg->replace ||
2411                  $change_to->cancel('no_delay_cancel' => 1) ||
2412                  $change_to->delete;
2413       } else {
2414         $error = $err_or_pkg;
2415       }
2416     } else { # change the start date only.
2417       $self->set('expire', $date);
2418       $change_to->set('start_date', $date);
2419       $error = $self->replace || $change_to->replace;
2420     }
2421     if ( $error ) {
2422       $dbh->rollback if $oldAutoCommit;
2423       return $error;
2424     } else {
2425       $dbh->commit if $oldAutoCommit;
2426       return '';
2427     }
2428   } # if $self->change_to_pkgnum
2429
2430   my $new_pkgpart = $opt->{'pkgpart'}
2431       if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart;
2432   my $new_locationnum = $opt->{'locationnum'}
2433       if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum;
2434   my $new_quantity = $opt->{'quantity'}
2435       if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity;
2436
2437   return '' unless $new_pkgpart or $new_locationnum or $new_quantity; # wouldn't do anything
2438
2439   # allow $opt->{'locationnum'} = '' to specifically set it to null
2440   # (i.e. customer default location)
2441   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2442
2443   my $new = FS::cust_pkg->new( {
2444     custnum     => $self->custnum,
2445     locationnum => $opt->{'locationnum'},
2446     start_date  => $date,
2447     map   {  $_ => ( $opt->{$_} || $self->$_() )  }
2448       qw( pkgpart quantity refnum salesnum )
2449   } );
2450   $error = $new->insert('change' => 1, 
2451                         'allow_pkgpart' => ($new_pkgpart ? 0 : 1));
2452   if ( !$error ) {
2453     $self->set('change_to_pkgnum', $new->pkgnum);
2454     $self->set('expire', $date);
2455     $error = $self->replace;
2456   }
2457   if ( $error ) {
2458     $dbh->rollback if $oldAutoCommit;
2459   } else {
2460     $dbh->commit if $oldAutoCommit;
2461   }
2462
2463   $error;
2464 }
2465
2466 =item abort_change
2467
2468 Cancels a future package change scheduled by C<change_later>.
2469
2470 =cut
2471
2472 sub abort_change {
2473   my $self = shift;
2474   my $pkgnum = $self->change_to_pkgnum;
2475   my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum;
2476   my $error;
2477   if ( $change_to ) {
2478     $error = $change_to->cancel || $change_to->delete;
2479     return $error if $error;
2480   }
2481   $self->set('change_to_pkgnum', '');
2482   $self->set('expire', '');
2483   $self->replace;
2484 }
2485
2486 =item set_quantity QUANTITY
2487
2488 Change the package's quantity field.  This is one of the few package properties
2489 that can safely be changed without canceling and reordering the package
2490 (because it doesn't affect tax eligibility).  Returns an error or an 
2491 empty string.
2492
2493 =cut
2494
2495 sub set_quantity {
2496   my $self = shift;
2497   $self = $self->replace_old; # just to make sure
2498   $self->quantity(shift);
2499   $self->replace;
2500 }
2501
2502 =item set_salesnum SALESNUM
2503
2504 Change the package's salesnum (sales person) field.  This is one of the few
2505 package properties that can safely be changed without canceling and reordering
2506 the package (because it doesn't affect tax eligibility).  Returns an error or
2507 an empty string.
2508
2509 =cut
2510
2511 sub set_salesnum {
2512   my $self = shift;
2513   $self = $self->replace_old; # just to make sure
2514   $self->salesnum(shift);
2515   $self->replace;
2516   # XXX this should probably reassign any credit that's already been given
2517 }
2518
2519 =item modify_charge OPTIONS
2520
2521 Change the properties of a one-time charge.  The following properties can
2522 be changed this way:
2523 - pkg: the package description
2524 - classnum: the package class
2525 - additional: arrayref of additional invoice details to add to this package
2526
2527 and, I<if the charge has not yet been billed>:
2528 - start_date: the date when it will be billed
2529 - amount: the setup fee to be charged
2530 - quantity: the multiplier for the setup fee
2531 - separate_bill: whether to put the charge on a separate invoice
2532
2533 If you pass 'adjust_commission' => 1, and the classnum changes, and there are
2534 commission credits linked to this charge, they will be recalculated.
2535
2536 =cut
2537
2538 sub modify_charge {
2539   my $self = shift;
2540   my %opt = @_;
2541   my $part_pkg = $self->part_pkg;
2542   my $pkgnum = $self->pkgnum;
2543
2544   my $dbh = dbh;
2545   my $oldAutoCommit = $FS::UID::AutoCommit;
2546   local $FS::UID::AutoCommit = 0;
2547
2548   return "Can't use modify_charge except on one-time charges"
2549     unless $part_pkg->freq eq '0';
2550
2551   if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) {
2552     $part_pkg->set('pkg', $opt{'pkg'});
2553   }
2554
2555   my %pkg_opt = $part_pkg->options;
2556   my $pkg_opt_modified = 0;
2557
2558   $opt{'additional'} ||= [];
2559   my $i;
2560   my @old_additional;
2561   foreach (grep /^additional/, keys %pkg_opt) {
2562     ($i) = ($_ =~ /^additional_info(\d+)$/);
2563     $old_additional[$i] = $pkg_opt{$_} if $i;
2564     delete $pkg_opt{$_};
2565   }
2566
2567   for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) {
2568     $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i];
2569     if (!exists($old_additional[$i])
2570         or $old_additional[$i] ne $opt{'additional'}->[$i])
2571     {
2572       $pkg_opt_modified = 1;
2573     }
2574   }
2575   $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i;
2576   $pkg_opt{'additional_count'} = $i if $i > 0;
2577
2578   my $old_classnum;
2579   if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} )
2580   {
2581     # remember it
2582     $old_classnum = $part_pkg->classnum;
2583     $part_pkg->set('classnum', $opt{'classnum'});
2584   }
2585
2586   if ( !$self->get('setup') ) {
2587     # not yet billed, so allow amount, setup_cost, quantity, start_date,
2588     # and separate_bill
2589
2590     if ( exists($opt{'amount'}) 
2591           and $part_pkg->option('setup_fee') != $opt{'amount'}
2592           and $opt{'amount'} > 0 ) {
2593
2594       $pkg_opt{'setup_fee'} = $opt{'amount'};
2595       $pkg_opt_modified = 1;
2596     }
2597
2598     if ( exists($opt{'setup_cost'}) 
2599           and $part_pkg->setup_cost != $opt{'setup_cost'}
2600           and $opt{'setup_cost'} > 0 ) {
2601
2602       $part_pkg->set('setup_cost', $opt{'setup_cost'});
2603     }
2604
2605     if ( exists($opt{'quantity'})
2606           and $opt{'quantity'} != $self->quantity
2607           and $opt{'quantity'} > 0 ) {
2608         
2609       $self->set('quantity', $opt{'quantity'});
2610     }
2611
2612     if ( exists($opt{'start_date'})
2613           and $opt{'start_date'} != $self->start_date ) {
2614
2615       $self->set('start_date', $opt{'start_date'});
2616     }
2617
2618     if ( exists($opt{'separate_bill'})
2619           and $opt{'separate_bill'} ne $self->separate_bill ) {
2620
2621       $self->set('separate_bill', $opt{'separate_bill'});
2622     }
2623
2624
2625   } # else simply ignore them; the UI shouldn't allow editing the fields
2626
2627   if ( exists($opt{'taxclass'}) 
2628           and $part_pkg->taxclass ne $opt{'taxclass'}) {
2629         
2630       $part_pkg->set('taxclass', $opt{'taxclass'});
2631   }
2632
2633   my $error;
2634   if ( $part_pkg->modified or $pkg_opt_modified ) {
2635     # can we safely modify the package def?
2636     # Yes, if it's not available for purchase, and this is the only instance
2637     # of it.
2638     if ( $part_pkg->disabled
2639          and FS::cust_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 1
2640          and FS::quotation_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 0
2641        ) {
2642       $error = $part_pkg->replace( options => \%pkg_opt );
2643     } else {
2644       # clone it
2645       $part_pkg = $part_pkg->clone;
2646       $part_pkg->set('disabled' => 'Y');
2647       $error = $part_pkg->insert( options => \%pkg_opt );
2648       # and associate this as yet-unbilled package to the new package def
2649       $self->set('pkgpart' => $part_pkg->pkgpart);
2650     }
2651     if ( $error ) {
2652       $dbh->rollback if $oldAutoCommit;
2653       return $error;
2654     }
2655   }
2656
2657   if ($self->modified) { # for quantity or start_date change, or if we had
2658                          # to clone the existing package def
2659     my $error = $self->replace;
2660     return $error if $error;
2661   }
2662   if (defined $old_classnum) {
2663     # fix invoice grouping records
2664     my $old_catname = $old_classnum
2665                       ? FS::pkg_class->by_key($old_classnum)->categoryname
2666                       : '';
2667     my $new_catname = $opt{'classnum'}
2668                       ? $part_pkg->pkg_class->categoryname
2669                       : '';
2670     if ( $old_catname ne $new_catname ) {
2671       foreach my $cust_bill_pkg ($self->cust_bill_pkg) {
2672         # (there should only be one...)
2673         my @display = qsearch( 'cust_bill_pkg_display', {
2674             'billpkgnum'  => $cust_bill_pkg->billpkgnum,
2675             'section'     => $old_catname,
2676         });
2677         foreach (@display) {
2678           $_->set('section', $new_catname);
2679           $error = $_->replace;
2680           if ( $error ) {
2681             $dbh->rollback if $oldAutoCommit;
2682             return $error;
2683           }
2684         }
2685       } # foreach $cust_bill_pkg
2686     }
2687
2688     if ( $opt{'adjust_commission'} ) {
2689       # fix commission credits...tricky.
2690       foreach my $cust_event ($self->cust_event) {
2691         my $part_event = $cust_event->part_event;
2692         foreach my $table (qw(sales agent)) {
2693           my $class =
2694             "FS::part_event::Action::Mixin::credit_${table}_pkg_class";
2695           my $credit = qsearchs('cust_credit', {
2696               'eventnum' => $cust_event->eventnum,
2697           });
2698           if ( $part_event->isa($class) ) {
2699             # Yes, this results in current commission rates being applied 
2700             # retroactively to a one-time charge.  For accounting purposes 
2701             # there ought to be some kind of time limit on doing this.
2702             my $amount = $part_event->_calc_credit($self);
2703             if ( $credit and $credit->amount ne $amount ) {
2704               # Void the old credit.
2705               $error = $credit->void('Package class changed');
2706               if ( $error ) {
2707                 $dbh->rollback if $oldAutoCommit;
2708                 return "$error (adjusting commission credit)";
2709               }
2710             }
2711             # redo the event action to recreate the credit.
2712             local $@ = '';
2713             eval { $part_event->do_action( $self, $cust_event ) };
2714             if ( $@ ) {
2715               $dbh->rollback if $oldAutoCommit;
2716               return $@;
2717             }
2718           } # if $part_event->isa($class)
2719         } # foreach $table
2720       } # foreach $cust_event
2721     } # if $opt{'adjust_commission'}
2722   } # if defined $old_classnum
2723
2724   $dbh->commit if $oldAutoCommit;
2725   '';
2726 }
2727
2728
2729
2730 use Storable 'thaw';
2731 use MIME::Base64;
2732 use Data::Dumper;
2733 sub process_bulk_cust_pkg {
2734   my $job = shift;
2735   my $param = thaw(decode_base64(shift));
2736   warn Dumper($param) if $DEBUG;
2737
2738   my $old_part_pkg = qsearchs('part_pkg', 
2739                               { pkgpart => $param->{'old_pkgpart'} });
2740   my $new_part_pkg = qsearchs('part_pkg',
2741                               { pkgpart => $param->{'new_pkgpart'} });
2742   die "Must select a new package type\n" unless $new_part_pkg;
2743   #my $keep_dates = $param->{'keep_dates'} || 0;
2744   my $keep_dates = 1; # there is no good reason to turn this off
2745
2746   local $SIG{HUP} = 'IGNORE';
2747   local $SIG{INT} = 'IGNORE';
2748   local $SIG{QUIT} = 'IGNORE';
2749   local $SIG{TERM} = 'IGNORE';
2750   local $SIG{TSTP} = 'IGNORE';
2751   local $SIG{PIPE} = 'IGNORE';
2752
2753   my $oldAutoCommit = $FS::UID::AutoCommit;
2754   local $FS::UID::AutoCommit = 0;
2755   my $dbh = dbh;
2756
2757   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
2758
2759   my $i = 0;
2760   foreach my $old_cust_pkg ( @cust_pkgs ) {
2761     $i++;
2762     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
2763     if ( $old_cust_pkg->getfield('cancel') ) {
2764       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
2765         $old_cust_pkg->pkgnum."\n"
2766         if $DEBUG;
2767       next;
2768     }
2769     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
2770       if $DEBUG;
2771     my $error = $old_cust_pkg->change(
2772       'pkgpart'     => $param->{'new_pkgpart'},
2773       'keep_dates'  => $keep_dates
2774     );
2775     if ( !ref($error) ) { # change returns the cust_pkg on success
2776       $dbh->rollback;
2777       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
2778     }
2779   }
2780   $dbh->commit if $oldAutoCommit;
2781   return;
2782 }
2783
2784 =item last_bill
2785
2786 Returns the last bill date, or if there is no last bill date, the setup date.
2787 Useful for billing metered services.
2788
2789 =cut
2790
2791 sub last_bill {
2792   my $self = shift;
2793   return $self->setfield('last_bill', $_[0]) if @_;
2794   return $self->getfield('last_bill') if $self->getfield('last_bill');
2795   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
2796                                                   'edate'  => $self->bill,  } );
2797   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
2798 }
2799
2800 =item last_cust_pkg_reason ACTION
2801
2802 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
2803 Returns false if there is no reason or the package is not currenly ACTION'd
2804 ACTION is one of adjourn, susp, cancel, or expire.
2805
2806 =cut
2807
2808 sub last_cust_pkg_reason {
2809   my ( $self, $action ) = ( shift, shift );
2810   my $date = $self->get($action);
2811   qsearchs( {
2812               'table' => 'cust_pkg_reason',
2813               'hashref' => { 'pkgnum' => $self->pkgnum,
2814                              'action' => substr(uc($action), 0, 1),
2815                              'date'   => $date,
2816                            },
2817               'order_by' => 'ORDER BY num DESC LIMIT 1',
2818            } );
2819 }
2820
2821 =item last_reason ACTION
2822
2823 Returns the most recent ACTION FS::reason associated with the package.
2824 Returns false if there is no reason or the package is not currenly ACTION'd
2825 ACTION is one of adjourn, susp, cancel, or expire.
2826
2827 =cut
2828
2829 sub last_reason {
2830   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
2831   $cust_pkg_reason->reason
2832     if $cust_pkg_reason;
2833 }
2834
2835 =item part_pkg
2836
2837 Returns the definition for this billing item, as an FS::part_pkg object (see
2838 L<FS::part_pkg>).
2839
2840 =cut
2841
2842 sub part_pkg {
2843   my $self = shift;
2844   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
2845   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
2846   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
2847 }
2848
2849 =item old_cust_pkg
2850
2851 Returns the cancelled package this package was changed from, if any.
2852
2853 =cut
2854
2855 sub old_cust_pkg {
2856   my $self = shift;
2857   return '' unless $self->change_pkgnum;
2858   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
2859 }
2860
2861 =item change_cust_main
2862
2863 Returns the customter this package was detached to, if any.
2864
2865 =cut
2866
2867 sub change_cust_main {
2868   my $self = shift;
2869   return '' unless $self->change_custnum;
2870   qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
2871 }
2872
2873 =item calc_setup
2874
2875 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
2876 item.
2877
2878 =cut
2879
2880 sub calc_setup {
2881   my $self = shift;
2882   $self->part_pkg->calc_setup($self, @_);
2883 }
2884
2885 =item calc_recur
2886
2887 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
2888 item.
2889
2890 =cut
2891
2892 sub calc_recur {
2893   my $self = shift;
2894   $self->part_pkg->calc_recur($self, @_);
2895 }
2896
2897 =item base_recur
2898
2899 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
2900 item.
2901
2902 =cut
2903
2904 sub base_recur {
2905   my $self = shift;
2906   $self->part_pkg->base_recur($self, @_);
2907 }
2908
2909 =item calc_remain
2910
2911 Calls the I<calc_remain> of the FS::part_pkg object associated with this
2912 billing item.
2913
2914 =cut
2915
2916 sub calc_remain {
2917   my $self = shift;
2918   $self->part_pkg->calc_remain($self, @_);
2919 }
2920
2921 =item calc_cancel
2922
2923 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
2924 billing item.
2925
2926 =cut
2927
2928 sub calc_cancel {
2929   my $self = shift;
2930   $self->part_pkg->calc_cancel($self, @_);
2931 }
2932
2933 =item cust_bill_pkg
2934
2935 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
2936
2937 =cut
2938
2939 sub cust_bill_pkg {
2940   my $self = shift;
2941   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
2942 }
2943
2944 =item cust_pkg_detail [ DETAILTYPE ]
2945
2946 Returns any customer package details for this package (see
2947 L<FS::cust_pkg_detail>).
2948
2949 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2950
2951 =cut
2952
2953 sub cust_pkg_detail {
2954   my $self = shift;
2955   my %hash = ( 'pkgnum' => $self->pkgnum );
2956   $hash{detailtype} = shift if @_;
2957   qsearch({
2958     'table'    => 'cust_pkg_detail',
2959     'hashref'  => \%hash,
2960     'order_by' => 'ORDER BY weight, pkgdetailnum',
2961   });
2962 }
2963
2964 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
2965
2966 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
2967
2968 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
2969
2970 If there is an error, returns the error, otherwise returns false.
2971
2972 =cut
2973
2974 sub set_cust_pkg_detail {
2975   my( $self, $detailtype, @details ) = @_;
2976
2977   local $SIG{HUP} = 'IGNORE';
2978   local $SIG{INT} = 'IGNORE';
2979   local $SIG{QUIT} = 'IGNORE';
2980   local $SIG{TERM} = 'IGNORE';
2981   local $SIG{TSTP} = 'IGNORE';
2982   local $SIG{PIPE} = 'IGNORE';
2983
2984   my $oldAutoCommit = $FS::UID::AutoCommit;
2985   local $FS::UID::AutoCommit = 0;
2986   my $dbh = dbh;
2987
2988   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
2989     my $error = $current->delete;
2990     if ( $error ) {
2991       $dbh->rollback if $oldAutoCommit;
2992       return "error removing old detail: $error";
2993     }
2994   }
2995
2996   foreach my $detail ( @details ) {
2997     my $cust_pkg_detail = new FS::cust_pkg_detail {
2998       'pkgnum'     => $self->pkgnum,
2999       'detailtype' => $detailtype,
3000       'detail'     => $detail,
3001     };
3002     my $error = $cust_pkg_detail->insert;
3003     if ( $error ) {
3004       $dbh->rollback if $oldAutoCommit;
3005       return "error adding new detail: $error";
3006     }
3007
3008   }
3009
3010   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3011   '';
3012
3013 }
3014
3015 =item cust_event
3016
3017 Returns the customer billing events (see L<FS::cust_event>) for this invoice.
3018
3019 =cut
3020
3021 #false laziness w/cust_bill.pm
3022 sub cust_event {
3023   my $self = shift;
3024   qsearch({
3025     'table'     => 'cust_event',
3026     'addl_from' => 'JOIN part_event USING ( eventpart )',
3027     'hashref'   => { 'tablenum' => $self->pkgnum },
3028     'extra_sql' => " AND eventtable = 'cust_pkg' ",
3029   });
3030 }
3031
3032 =item num_cust_event
3033
3034 Returns the number of customer billing events (see L<FS::cust_event>) for this package.
3035
3036 =cut
3037
3038 #false laziness w/cust_bill.pm
3039 sub num_cust_event {
3040   my $self = shift;
3041   my $sql = "SELECT COUNT(*) ". $self->_from_cust_event_where;
3042   $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref->[0];
3043 }
3044
3045 =item exists_cust_event
3046
3047 Returns true if there are customer billing events (see L<FS::cust_event>) for this package.  More efficient than using num_cust_event.
3048
3049 =cut
3050
3051 sub exists_cust_event {
3052   my $self = shift;
3053   my $sql = "SELECT 1 ". $self->_from_cust_event_where. " LIMIT 1";
3054   my $row = $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref;
3055   $row ? $row->[0] : '';
3056 }
3057
3058 sub _from_cust_event_where {
3059   #my $self = shift;
3060   " FROM cust_event JOIN part_event USING ( eventpart ) ".
3061   "  WHERE tablenum = ? AND eventtable = 'cust_pkg' ";
3062 }
3063
3064 sub _prep_ex {
3065   my( $self, $sql, @args ) = @_;
3066   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
3067   $sth->execute(@args)         or die $sth->errstr. " executing $sql";
3068   $sth;
3069 }
3070
3071 =item cust_svc [ SVCPART ] (old, deprecated usage)
3072
3073 =item cust_svc [ OPTION => VALUE ... ] (current usage)
3074
3075 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
3076
3077 Returns the services for this package, as FS::cust_svc objects (see
3078 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
3079 spcififed, returns only the matching services.
3080
3081 As an optimization, use the cust_svc_unsorted version if you are not displaying
3082 the results.
3083
3084 =cut
3085
3086 sub cust_svc {
3087   my $self = shift;
3088   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
3089   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) );
3090 }
3091
3092 sub cust_svc_unsorted {
3093   my $self = shift;
3094   @{ $self->cust_svc_unsorted_arrayref(@_) };
3095 }
3096
3097 sub cust_svc_unsorted_arrayref {
3098   my $self = shift;
3099
3100   return [] unless $self->num_cust_svc(@_);
3101
3102   my %opt = ();
3103   if ( @_ && $_[0] =~ /^\d+/ ) {
3104     $opt{svcpart} = shift;
3105   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3106     %opt = %{ $_[0] };
3107   } elsif ( @_ ) {
3108     %opt = @_;
3109   }
3110
3111   my %search = (
3112     'table'   => 'cust_svc',
3113     'hashref' => { 'pkgnum' => $self->pkgnum },
3114   );
3115   if ( $opt{svcpart} ) {
3116     $search{hashref}->{svcpart} = $opt{'svcpart'};
3117   }
3118   if ( $opt{'svcdb'} ) {
3119     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
3120     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
3121   }
3122
3123   [ qsearch(\%search) ];
3124
3125 }
3126
3127 =item overlimit [ SVCPART ]
3128
3129 Returns the services for this package which have exceeded their
3130 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
3131 is specified, return only the matching services.
3132
3133 =cut
3134
3135 sub overlimit {
3136   my $self = shift;
3137   return () unless $self->num_cust_svc(@_);
3138   grep { $_->overlimit } $self->cust_svc(@_);
3139 }
3140
3141 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3142
3143 Returns historical services for this package created before END TIMESTAMP and
3144 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
3145 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
3146 I<pkg_svc.hidden> flag will be omitted.
3147
3148 =cut
3149
3150 sub h_cust_svc {
3151   my $self = shift;
3152   warn "$me _h_cust_svc called on $self\n"
3153     if $DEBUG;
3154
3155   my ($end, $start, $mode) = @_;
3156   my @cust_svc = $self->_sort_cust_svc(
3157     [ qsearch( 'h_cust_svc',
3158       { 'pkgnum' => $self->pkgnum, },  
3159       FS::h_cust_svc->sql_h_search(@_),  
3160     ) ]
3161   );
3162   if ( defined($mode) && $mode eq 'I' ) {
3163     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
3164     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
3165   } else {
3166     return @cust_svc;
3167   }
3168 }
3169
3170 sub _sort_cust_svc {
3171   my( $self, $arrayref ) = @_;
3172
3173   my $sort =
3174     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
3175
3176   my %pkg_svc = map { $_->svcpart => $_ }
3177                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
3178
3179   map  { $_->[0] }
3180   sort $sort
3181   map {
3182         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
3183         [ $_,
3184           $pkg_svc ? $pkg_svc->primary_svc : '',
3185           $pkg_svc ? $pkg_svc->quantity : 0,
3186         ];
3187       }
3188   @$arrayref;
3189
3190 }
3191
3192 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
3193
3194 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
3195
3196 Returns the number of services for this package.  Available options are svcpart
3197 and svcdb.  If either is spcififed, returns only the matching services.
3198
3199 =cut
3200
3201 sub num_cust_svc {
3202   my $self = shift;
3203
3204   return $self->{'_num_cust_svc'}
3205     if !scalar(@_)
3206        && exists($self->{'_num_cust_svc'})
3207        && $self->{'_num_cust_svc'} =~ /\d/;
3208
3209   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
3210     if $DEBUG > 2;
3211
3212   my %opt = ();
3213   if ( @_ && $_[0] =~ /^\d+/ ) {
3214     $opt{svcpart} = shift;
3215   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3216     %opt = %{ $_[0] };
3217   } elsif ( @_ ) {
3218     %opt = @_;
3219   }
3220
3221   my $select = 'SELECT COUNT(*) FROM cust_svc ';
3222   my $where = ' WHERE pkgnum = ? ';
3223   my @param = ($self->pkgnum);
3224
3225   if ( $opt{'svcpart'} ) {
3226     $where .= ' AND svcpart = ? ';
3227     push @param, $opt{'svcpart'};
3228   }
3229   if ( $opt{'svcdb'} ) {
3230     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
3231     $where .= ' AND svcdb = ? ';
3232     push @param, $opt{'svcdb'};
3233   }
3234
3235   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
3236   $sth->execute(@param) or die $sth->errstr;
3237   $sth->fetchrow_arrayref->[0];
3238 }
3239
3240 =item available_part_svc 
3241
3242 Returns a list of FS::part_svc objects representing services included in this
3243 package but not yet provisioned.  Each FS::part_svc object also has an extra
3244 field, I<num_avail>, which specifies the number of available services.
3245
3246 =cut
3247
3248 sub available_part_svc {
3249   my $self = shift;
3250
3251   my $pkg_quantity = $self->quantity || 1;
3252
3253   grep { $_->num_avail > 0 }
3254     map {
3255           my $part_svc = $_->part_svc;
3256           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
3257             $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
3258
3259           # more evil encapsulation breakage
3260           if($part_svc->{'Hash'}{'num_avail'} > 0) {
3261             my @exports = $part_svc->part_export_did;
3262             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
3263           }
3264
3265           $part_svc;
3266         }
3267       $self->part_pkg->pkg_svc;
3268 }
3269
3270 =item part_svc [ OPTION => VALUE ... ]
3271
3272 Returns a list of FS::part_svc objects representing provisioned and available
3273 services included in this package.  Each FS::part_svc object also has the
3274 following extra fields:
3275
3276 =over 4
3277
3278 =item num_cust_svc
3279
3280 (count)
3281
3282 =item num_avail
3283
3284 (quantity - count)
3285
3286 =item cust_pkg_svc
3287
3288 (services) - array reference containing the provisioned services, as cust_svc objects
3289
3290 =back
3291
3292 Accepts two options:
3293
3294 =over 4
3295
3296 =item summarize_size
3297
3298 If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc
3299 is this size or greater.
3300
3301 =item hide_discontinued
3302
3303 If true, will omit looking for services that are no longer avaialble in the
3304 package definition.
3305
3306 =back
3307
3308 =cut
3309
3310 #svcnum
3311 #label -> ($cust_svc->label)[1]
3312
3313 sub part_svc {
3314   my $self = shift;
3315   my %opt = @_;
3316
3317   my $pkg_quantity = $self->quantity || 1;
3318
3319   #XXX some sort of sort order besides numeric by svcpart...
3320   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
3321     my $pkg_svc = $_;
3322     my $part_svc = $pkg_svc->part_svc;
3323     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3324     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
3325     $part_svc->{'Hash'}{'num_avail'}    =
3326       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
3327     $part_svc->{'Hash'}{'cust_pkg_svc'} =
3328         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
3329       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
3330           && $num_cust_svc >= $opt{summarize_size};
3331     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
3332     $part_svc;
3333   } $self->part_pkg->pkg_svc;
3334
3335   unless ( $opt{hide_discontinued} ) {
3336     #extras
3337     push @part_svc, map {
3338       my $part_svc = $_;
3339       my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3340       $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
3341       $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
3342       $part_svc->{'Hash'}{'cust_pkg_svc'} =
3343         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
3344       $part_svc;
3345     } $self->extra_part_svc;
3346   }
3347
3348   @part_svc;
3349
3350 }
3351
3352 =item extra_part_svc
3353
3354 Returns a list of FS::part_svc objects corresponding to services in this
3355 package which are still provisioned but not (any longer) available in the
3356 package definition.
3357
3358 =cut
3359
3360 sub extra_part_svc {
3361   my $self = shift;
3362
3363   my $pkgnum  = $self->pkgnum;
3364   #my $pkgpart = $self->pkgpart;
3365
3366 #  qsearch( {
3367 #    'table'     => 'part_svc',
3368 #    'hashref'   => {},
3369 #    'extra_sql' =>
3370 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
3371 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
3372 #                       AND pkg_svc.pkgpart = ?
3373 #                       AND quantity > 0 
3374 #                 )
3375 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
3376 #                       LEFT JOIN cust_pkg USING ( pkgnum )
3377 #                     WHERE cust_svc.svcpart = part_svc.svcpart
3378 #                       AND pkgnum = ?
3379 #                 )",
3380 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
3381 #  } );
3382
3383 #seems to benchmark slightly faster... (or did?)
3384
3385   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
3386   my $pkgparts = join(',', @pkgparts);
3387
3388   qsearch( {
3389     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
3390     #MySQL doesn't grok DISINCT ON
3391     'select'      => 'DISTINCT part_svc.*',
3392     'table'       => 'part_svc',
3393     'addl_from'   =>
3394       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
3395                                AND pkg_svc.pkgpart IN ($pkgparts)
3396                                AND quantity > 0
3397                              )
3398        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
3399        LEFT JOIN cust_pkg USING ( pkgnum )
3400       ",
3401     'hashref'     => {},
3402     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
3403     'extra_param' => [ [$self->pkgnum=>'int'] ],
3404   } );
3405 }
3406
3407 =item status
3408
3409 Returns a short status string for this package, currently:
3410
3411 =over 4
3412
3413 =item on hold
3414
3415 =item not yet billed
3416
3417 =item one-time charge
3418
3419 =item active
3420
3421 =item suspended
3422
3423 =item cancelled
3424
3425 =back
3426
3427 =cut
3428
3429 sub status {
3430   my $self = shift;
3431
3432   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
3433
3434   return 'cancelled' if $self->get('cancel');
3435   return 'on hold' if $self->susp && ! $self->setup;
3436   return 'suspended' if $self->susp;
3437   return 'not yet billed' unless $self->setup;
3438   return 'one-time charge' if $freq =~ /^(0|$)/;
3439   return 'active';
3440 }
3441
3442 =item ucfirst_status
3443
3444 Returns the status with the first character capitalized.
3445
3446 =cut
3447
3448 sub ucfirst_status {
3449   ucfirst(shift->status);
3450 }
3451
3452 =item statuses
3453
3454 Class method that returns the list of possible status strings for packages
3455 (see L<the status method|/status>).  For example:
3456
3457   @statuses = FS::cust_pkg->statuses();
3458
3459 =cut
3460
3461 tie my %statuscolor, 'Tie::IxHash', 
3462   'on hold'         => 'FF00F5', #brighter purple!
3463   'not yet billed'  => '009999', #teal? cyan?
3464   'one-time charge' => '0000CC', #blue  #'000000',
3465   'active'          => '00CC00',
3466   'suspended'       => 'FF9900',
3467   'cancelled'       => 'FF0000',
3468 ;
3469
3470 sub statuses {
3471   my $self = shift; #could be class...
3472   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
3473   #                                    # mayble split btw one-time vs. recur
3474     keys %statuscolor;
3475 }
3476
3477 sub statuscolors {
3478   #my $self = shift;
3479   \%statuscolor;
3480 }
3481
3482 =item statuscolor
3483
3484 Returns a hex triplet color string for this package's status.
3485
3486 =cut
3487
3488 sub statuscolor {
3489   my $self = shift;
3490   $statuscolor{$self->status};
3491 }
3492
3493 =item is_status_delay_cancel
3494
3495 Returns true if part_pkg has option delay_cancel, 
3496 cust_pkg status is 'suspended' and expire is set
3497 to cancel package within the next day (or however
3498 many days are set in global config part_pkg-delay_cancel-days.
3499
3500 This is not a real status, this only meant for hacking display 
3501 values, because otherwise treating the package as suspended is 
3502 really the whole point of the delay_cancel option.
3503
3504 =cut
3505
3506 sub is_status_delay_cancel {
3507   my ($self) = @_;
3508   if ( $self->main_pkgnum and $self->pkglinknum ) {
3509     return $self->main_pkg->is_status_delay_cancel;
3510   }
3511   return 0 unless $self->part_pkg->option('delay_cancel',1);
3512   return 0 unless $self->status eq 'suspended';
3513   return 0 unless $self->expire;
3514   my $conf = new FS::Conf;
3515   my $expdays = $conf->config('part_pkg-delay_cancel-days') || 1;
3516   my $expsecs = 60*60*24*$expdays;
3517   return 0 unless $self->expire < time + $expsecs;
3518   return 1;
3519 }
3520
3521 =item pkg_label
3522
3523 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3524 "pkg - comment" depending on user preference).
3525
3526 =cut
3527
3528 sub pkg_label {
3529   my $self = shift;
3530   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
3531   $label = $self->pkgnum. ": $label"
3532     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3533   $label;
3534 }
3535
3536 =item pkg_label_long
3537
3538 Returns a long label for this package, adding the primary service's label to
3539 pkg_label.
3540
3541 =cut
3542
3543 sub pkg_label_long {
3544   my $self = shift;
3545   my $label = $self->pkg_label;
3546   my $cust_svc = $self->primary_cust_svc;
3547   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3548   $label;
3549 }
3550
3551 =item pkg_locale
3552
3553 Returns a customer-localized label for this package.
3554
3555 =cut
3556
3557 sub pkg_locale {
3558   my $self = shift;
3559   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3560 }
3561
3562 =item primary_cust_svc
3563
3564 Returns a primary service (as FS::cust_svc object) if one can be identified.
3565
3566 =cut
3567
3568 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3569
3570 sub primary_cust_svc {
3571   my $self = shift;
3572
3573   my @cust_svc = $self->cust_svc;
3574
3575   return '' unless @cust_svc; #no serivces - irrelevant then
3576   
3577   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3578
3579   # primary service as specified in the package definition
3580   # or exactly one service definition with quantity one
3581   my $svcpart = $self->part_pkg->svcpart;
3582   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3583   return $cust_svc[0] if scalar(@cust_svc) == 1;
3584
3585   #couldn't identify one thing..
3586   return '';
3587 }
3588
3589 =item labels
3590
3591 Returns a list of lists, calling the label method for all services
3592 (see L<FS::cust_svc>) of this billing item.
3593
3594 =cut
3595
3596 sub labels {
3597   my $self = shift;
3598   map { [ $_->label ] } $self->cust_svc;
3599 }
3600
3601 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3602
3603 Like the labels method, but returns historical information on services that
3604 were active as of END_TIMESTAMP and (optionally) not cancelled before
3605 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3606 I<pkg_svc.hidden> flag will be omitted.
3607
3608 Returns a list of lists, calling the label method for all (historical) services
3609 (see L<FS::h_cust_svc>) of this billing item.
3610
3611 =cut
3612
3613 sub h_labels {
3614   my $self = shift;
3615   warn "$me _h_labels called on $self\n"
3616     if $DEBUG;
3617   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3618 }
3619
3620 =item labels_short
3621
3622 Like labels, except returns a simple flat list, and shortens long
3623 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3624 identical services to one line that lists the service label and the number of
3625 individual services rather than individual items.
3626
3627 =cut
3628
3629 sub labels_short {
3630   shift->_labels_short( 'labels', @_ );
3631 }
3632
3633 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3634
3635 Like h_labels, except returns a simple flat list, and shortens long
3636 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3637 identical services to one line that lists the service label and the number of
3638 individual services rather than individual items.
3639
3640 =cut
3641
3642 sub h_labels_short {
3643   shift->_labels_short( 'h_labels', @_ );
3644 }
3645
3646 sub _labels_short {
3647   my( $self, $method ) = ( shift, shift );
3648
3649   warn "$me _labels_short called on $self with $method method\n"
3650     if $DEBUG;
3651
3652   my $conf = new FS::Conf;
3653   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3654
3655   warn "$me _labels_short populating \%labels\n"
3656     if $DEBUG;
3657
3658   my %labels;
3659   #tie %labels, 'Tie::IxHash';
3660   push @{ $labels{$_->[0]} }, $_->[1]
3661     foreach $self->$method(@_);
3662
3663   warn "$me _labels_short populating \@labels\n"
3664     if $DEBUG;
3665
3666   my @labels;
3667   foreach my $label ( keys %labels ) {
3668     my %seen = ();
3669     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3670     my $num = scalar(@values);
3671     warn "$me _labels_short $num items for $label\n"
3672       if $DEBUG;
3673
3674     if ( $num > $max_same_services ) {
3675       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3676         if $DEBUG;
3677       push @labels, "$label ($num)";
3678     } else {
3679       if ( $conf->exists('cust_bill-consolidate_services') ) {
3680         warn "$me _labels_short   consolidating services\n"
3681           if $DEBUG;
3682         # push @labels, "$label: ". join(', ', @values);
3683         while ( @values ) {
3684           my $detail = "$label: ";
3685           $detail .= shift(@values). ', '
3686             while @values
3687                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3688           $detail =~ s/, $//;
3689           push @labels, $detail;
3690         }
3691         warn "$me _labels_short   done consolidating services\n"
3692           if $DEBUG;
3693       } else {
3694         warn "$me _labels_short   adding service data\n"
3695           if $DEBUG;
3696         push @labels, map { "$label: $_" } @values;
3697       }
3698     }
3699   }
3700
3701  @labels;
3702
3703 }
3704
3705 =item cust_main
3706
3707 Returns the parent customer object (see L<FS::cust_main>).
3708
3709 =cut
3710
3711 sub cust_main {
3712   my $self = shift;
3713   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
3714 }
3715
3716 =item balance
3717
3718 Returns the balance for this specific package, when using
3719 experimental package balance.
3720
3721 =cut
3722
3723 sub balance {
3724   my $self = shift;
3725   $self->cust_main->balance_pkgnum( $self->pkgnum );
3726 }
3727
3728 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3729
3730 =item cust_location
3731
3732 Returns the location object, if any (see L<FS::cust_location>).
3733
3734 =item cust_location_or_main
3735
3736 If this package is associated with a location, returns the locaiton (see
3737 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3738
3739 =item location_label [ OPTION => VALUE ... ]
3740
3741 Returns the label of the location object (see L<FS::cust_location>).
3742
3743 =cut
3744
3745 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3746
3747 =item tax_locationnum
3748
3749 Returns the foreign key to a L<FS::cust_location> object for calculating  
3750 tax on this package, as determined by the C<tax-pkg_address> and 
3751 C<tax-ship_address> configuration flags.
3752
3753 =cut
3754
3755 sub tax_locationnum {
3756   my $self = shift;
3757   my $conf = FS::Conf->new;
3758   if ( $conf->exists('tax-pkg_address') ) {
3759     return $self->locationnum;
3760   }
3761   elsif ( $conf->exists('tax-ship_address') ) {
3762     return $self->cust_main->ship_locationnum;
3763   }
3764   else {
3765     return $self->cust_main->bill_locationnum;
3766   }
3767 }
3768
3769 =item tax_location
3770
3771 Returns the L<FS::cust_location> object for tax_locationnum.
3772
3773 =cut
3774
3775 sub tax_location {
3776   my $self = shift;
3777   my $conf = FS::Conf->new;
3778   if ( $conf->exists('tax-pkg_address') and $self->locationnum ) {
3779     return FS::cust_location->by_key($self->locationnum);
3780   }
3781   elsif ( $conf->exists('tax-ship_address') ) {
3782     return $self->cust_main->ship_location;
3783   }
3784   else {
3785     return $self->cust_main->bill_location;
3786   }
3787 }
3788
3789 =item seconds_since TIMESTAMP
3790
3791 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3792 package have been online since TIMESTAMP, according to the session monitor.
3793
3794 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3795 L<Time::Local> and L<Date::Parse> for conversion functions.
3796
3797 =cut
3798
3799 sub seconds_since {
3800   my($self, $since) = @_;
3801   my $seconds = 0;
3802
3803   foreach my $cust_svc (
3804     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3805   ) {
3806     $seconds += $cust_svc->seconds_since($since);
3807   }
3808
3809   $seconds;
3810
3811 }
3812
3813 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3814
3815 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3816 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3817 (exclusive).
3818
3819 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3820 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3821 functions.
3822
3823
3824 =cut
3825
3826 sub seconds_since_sqlradacct {
3827   my($self, $start, $end) = @_;
3828
3829   my $seconds = 0;
3830
3831   foreach my $cust_svc (
3832     grep {
3833       my $part_svc = $_->part_svc;
3834       $part_svc->svcdb eq 'svc_acct'
3835         && scalar($part_svc->part_export_usage);
3836     } $self->cust_svc
3837   ) {
3838     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
3839   }
3840
3841   $seconds;
3842
3843 }
3844
3845 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
3846
3847 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
3848 in this package for sessions ending between TIMESTAMP_START (inclusive) and
3849 TIMESTAMP_END
3850 (exclusive).
3851
3852 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3853 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3854 functions.
3855
3856 =cut
3857
3858 sub attribute_since_sqlradacct {
3859   my($self, $start, $end, $attrib) = @_;
3860
3861   my $sum = 0;
3862
3863   foreach my $cust_svc (
3864     grep {
3865       my $part_svc = $_->part_svc;
3866       scalar($part_svc->part_export_usage);
3867     } $self->cust_svc
3868   ) {
3869     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
3870   }
3871
3872   $sum;
3873
3874 }
3875
3876 =item quantity
3877
3878 =cut
3879
3880 sub quantity {
3881   my( $self, $value ) = @_;
3882   if ( defined($value) ) {
3883     $self->setfield('quantity', $value);
3884   }
3885   $self->getfield('quantity') || 1;
3886 }
3887
3888 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
3889
3890 Transfers as many services as possible from this package to another package.
3891
3892 The destination package can be specified by pkgnum by passing an FS::cust_pkg
3893 object.  The destination package must already exist.
3894
3895 Services are moved only if the destination allows services with the correct
3896 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
3897 this option with caution!  No provision is made for export differences
3898 between the old and new service definitions.  Probably only should be used
3899 when your exports for all service definitions of a given svcdb are identical.
3900 (attempt a transfer without it first, to move all possible svcpart-matching
3901 services)
3902
3903 Any services that can't be moved remain in the original package.
3904
3905 Returns an error, if there is one; otherwise, returns the number of services 
3906 that couldn't be moved.
3907
3908 =cut
3909
3910 sub transfer {
3911   my ($self, $dest_pkgnum, %opt) = @_;
3912
3913   my $remaining = 0;
3914   my $dest;
3915   my %target;
3916
3917   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
3918     $dest = $dest_pkgnum;
3919     $dest_pkgnum = $dest->pkgnum;
3920   } else {
3921     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
3922   }
3923
3924   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
3925
3926   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
3927     $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 );
3928   }
3929
3930   foreach my $cust_svc ($dest->cust_svc) {
3931     $target{$cust_svc->svcpart}--;
3932   }
3933
3934   my %svcpart2svcparts = ();
3935   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3936     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
3937     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
3938       next if exists $svcpart2svcparts{$svcpart};
3939       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
3940       $svcpart2svcparts{$svcpart} = [
3941         map  { $_->[0] }
3942         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
3943         map {
3944               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
3945                                                    'svcpart' => $_          } );
3946               [ $_,
3947                 $pkg_svc ? $pkg_svc->primary_svc : '',
3948                 $pkg_svc ? $pkg_svc->quantity : 0,
3949               ];
3950             }
3951
3952         grep { $_ != $svcpart }
3953         map  { $_->svcpart }
3954         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
3955       ];
3956       warn "alternates for svcpart $svcpart: ".
3957            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
3958         if $DEBUG;
3959     }
3960   }
3961
3962   my $error;
3963   foreach my $cust_svc ($self->cust_svc) {
3964     my $svcnum = $cust_svc->svcnum;
3965     if($target{$cust_svc->svcpart} > 0
3966        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
3967       $target{$cust_svc->svcpart}--;
3968       my $new = new FS::cust_svc { $cust_svc->hash };
3969       $new->pkgnum($dest_pkgnum);
3970       $error = $new->replace($cust_svc);
3971     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
3972       if ( $DEBUG ) {
3973         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
3974         warn "alternates to consider: ".
3975              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
3976       }
3977       my @alternate = grep {
3978                              warn "considering alternate svcpart $_: ".
3979                                   "$target{$_} available in new package\n"
3980                                if $DEBUG;
3981                              $target{$_} > 0;
3982                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
3983       if ( @alternate ) {
3984         warn "alternate(s) found\n" if $DEBUG;
3985         my $change_svcpart = $alternate[0];
3986         $target{$change_svcpart}--;
3987         my $new = new FS::cust_svc { $cust_svc->hash };
3988         $new->svcpart($change_svcpart);
3989         $new->pkgnum($dest_pkgnum);
3990         $error = $new->replace($cust_svc);
3991       } else {
3992         $remaining++;
3993       }
3994     } else {
3995       $remaining++
3996     }
3997     if ( $error ) {
3998       my @label = $cust_svc->label;
3999       return "service $label[1]: $error";
4000     }
4001   }
4002   return $remaining;
4003 }
4004
4005 =item grab_svcnums SVCNUM, SVCNUM ...
4006
4007 Change the pkgnum for the provided services to this packages.  If there is an
4008 error, returns the error, otherwise returns false.
4009
4010 =cut
4011
4012 sub grab_svcnums {
4013   my $self = shift;
4014   my @svcnum = @_;
4015
4016   local $SIG{HUP} = 'IGNORE';
4017   local $SIG{INT} = 'IGNORE';
4018   local $SIG{QUIT} = 'IGNORE';
4019   local $SIG{TERM} = 'IGNORE';
4020   local $SIG{TSTP} = 'IGNORE';
4021   local $SIG{PIPE} = 'IGNORE';
4022
4023   my $oldAutoCommit = $FS::UID::AutoCommit;
4024   local $FS::UID::AutoCommit = 0;
4025   my $dbh = dbh;
4026
4027   foreach my $svcnum (@svcnum) {
4028     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
4029       $dbh->rollback if $oldAutoCommit;
4030       return "unknown svcnum $svcnum";
4031     };
4032     $cust_svc->pkgnum( $self->pkgnum );
4033     my $error = $cust_svc->replace;
4034     if ( $error ) {
4035       $dbh->rollback if $oldAutoCommit;
4036       return $error;
4037     }
4038   }
4039
4040   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4041   '';
4042
4043 }
4044
4045 =item reexport
4046
4047 This method is deprecated.  See the I<depend_jobnum> option to the insert and
4048 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
4049
4050 =cut
4051
4052 sub reexport {
4053   my $self = shift;
4054
4055   local $SIG{HUP} = 'IGNORE';
4056   local $SIG{INT} = 'IGNORE';
4057   local $SIG{QUIT} = 'IGNORE';
4058   local $SIG{TERM} = 'IGNORE';
4059   local $SIG{TSTP} = 'IGNORE';
4060   local $SIG{PIPE} = 'IGNORE';
4061
4062   my $oldAutoCommit = $FS::UID::AutoCommit;
4063   local $FS::UID::AutoCommit = 0;
4064   my $dbh = dbh;
4065
4066   foreach my $cust_svc ( $self->cust_svc ) {
4067     #false laziness w/svc_Common::insert
4068     my $svc_x = $cust_svc->svc_x;
4069     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
4070       my $error = $part_export->export_insert($svc_x);
4071       if ( $error ) {
4072         $dbh->rollback if $oldAutoCommit;
4073         return $error;
4074       }
4075     }
4076   }
4077
4078   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4079   '';
4080
4081 }
4082
4083 =item export_pkg_change OLD_CUST_PKG
4084
4085 Calls the "pkg_change" export action for all services attached to this package.
4086
4087 =cut
4088
4089 sub export_pkg_change {
4090   my( $self, $old )  = ( shift, shift );
4091
4092   local $SIG{HUP} = 'IGNORE';
4093   local $SIG{INT} = 'IGNORE';
4094   local $SIG{QUIT} = 'IGNORE';
4095   local $SIG{TERM} = 'IGNORE';
4096   local $SIG{TSTP} = 'IGNORE';
4097   local $SIG{PIPE} = 'IGNORE';
4098
4099   my $oldAutoCommit = $FS::UID::AutoCommit;
4100   local $FS::UID::AutoCommit = 0;
4101   my $dbh = dbh;
4102
4103   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
4104     my $error = $svc_x->export('pkg_change', $self, $old);
4105     if ( $error ) {
4106       $dbh->rollback if $oldAutoCommit;
4107       return $error;
4108     }
4109   }
4110
4111   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4112   '';
4113
4114 }
4115
4116 =item insert_reason
4117
4118 Associates this package with a (suspension or cancellation) reason (see
4119 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
4120 L<FS::reason>).
4121
4122 Available options are:
4123
4124 =over 4
4125
4126 =item reason
4127
4128 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.
4129
4130 =item reason_otaker
4131
4132 the access_user (see L<FS::access_user>) providing the reason
4133
4134 =item date
4135
4136 a unix timestamp 
4137
4138 =item action
4139
4140 the action (cancel, susp, adjourn, expire) associated with the reason
4141
4142 =back
4143
4144 If there is an error, returns the error, otherwise returns false.
4145
4146 =cut
4147
4148 sub insert_reason {
4149   my ($self, %options) = @_;
4150
4151   my $otaker = $options{reason_otaker} ||
4152                $FS::CurrentUser::CurrentUser->username;
4153
4154   my $reasonnum;
4155   if ( $options{'reason'} =~ /^(\d+)$/ ) {
4156
4157     $reasonnum = $1;
4158
4159   } elsif ( ref($options{'reason'}) ) {
4160   
4161     return 'Enter a new reason (or select an existing one)'
4162       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
4163
4164     my $reason = new FS::reason({
4165       'reason_type' => $options{'reason'}->{'typenum'},
4166       'reason'      => $options{'reason'}->{'reason'},
4167     });
4168     my $error = $reason->insert;
4169     return $error if $error;
4170
4171     $reasonnum = $reason->reasonnum;
4172
4173   } else {
4174     return "Unparseable reason: ". $options{'reason'};
4175   }
4176
4177   my $cust_pkg_reason =
4178     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
4179                               'reasonnum' => $reasonnum, 
4180                               'otaker'    => $otaker,
4181                               'action'    => substr(uc($options{'action'}),0,1),
4182                               'date'      => $options{'date'}
4183                                                ? $options{'date'}
4184                                                : time,
4185                             });
4186
4187   $cust_pkg_reason->insert;
4188 }
4189
4190 =item insert_discount
4191
4192 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
4193 inserting a new discount on the fly (see L<FS::discount>).
4194
4195 Available options are:
4196
4197 =over 4
4198
4199 =item discountnum
4200
4201 =back
4202
4203 If there is an error, returns the error, otherwise returns false.
4204
4205 =cut
4206
4207 sub insert_discount {
4208   #my ($self, %options) = @_;
4209   my $self = shift;
4210
4211   my $cust_pkg_discount = new FS::cust_pkg_discount {
4212     'pkgnum'      => $self->pkgnum,
4213     'discountnum' => $self->discountnum,
4214     'months_used' => 0,
4215     'end_date'    => '', #XXX
4216     #for the create a new discount case
4217     '_type'       => $self->discountnum__type,
4218     'amount'      => $self->discountnum_amount,
4219     'percent'     => $self->discountnum_percent,
4220     'months'      => $self->discountnum_months,
4221     'setup'      => $self->discountnum_setup,
4222     #'disabled'    => $self->discountnum_disabled,
4223   };
4224
4225   $cust_pkg_discount->insert;
4226 }
4227
4228 =item set_usage USAGE_VALUE_HASHREF 
4229
4230 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4231 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4232 upbytes, downbytes, and totalbytes are appropriate keys.
4233
4234 All svc_accts which are part of this package have their values reset.
4235
4236 =cut
4237
4238 sub set_usage {
4239   my ($self, $valueref, %opt) = @_;
4240
4241   #only svc_acct can set_usage for now
4242   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4243     my $svc_x = $cust_svc->svc_x;
4244     $svc_x->set_usage($valueref, %opt)
4245       if $svc_x->can("set_usage");
4246   }
4247 }
4248
4249 =item recharge USAGE_VALUE_HASHREF 
4250
4251 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4252 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4253 upbytes, downbytes, and totalbytes are appropriate keys.
4254
4255 All svc_accts which are part of this package have their values incremented.
4256
4257 =cut
4258
4259 sub recharge {
4260   my ($self, $valueref) = @_;
4261
4262   #only svc_acct can set_usage for now
4263   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4264     my $svc_x = $cust_svc->svc_x;
4265     $svc_x->recharge($valueref)
4266       if $svc_x->can("recharge");
4267   }
4268 }
4269
4270 =item cust_pkg_discount
4271
4272 =cut
4273
4274 sub cust_pkg_discount {
4275   my $self = shift;
4276   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
4277 }
4278
4279 =item cust_pkg_discount_active
4280
4281 =cut
4282
4283 sub cust_pkg_discount_active {
4284   my $self = shift;
4285   grep { $_->status eq 'active' } $self->cust_pkg_discount;
4286 }
4287
4288 =item cust_pkg_usage
4289
4290 Returns a list of all voice usage counters attached to this package.
4291
4292 =cut
4293
4294 sub cust_pkg_usage {
4295   my $self = shift;
4296   qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
4297 }
4298
4299 =item apply_usage OPTIONS
4300
4301 Takes the following options:
4302 - cdr: a call detail record (L<FS::cdr>)
4303 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
4304 - minutes: the maximum number of minutes to be charged
4305
4306 Finds available usage minutes for a call of this class, and subtracts
4307 up to that many minutes from the usage pool.  If the usage pool is empty,
4308 and the C<cdr-minutes_priority> global config option is set, minutes may
4309 be taken from other calls as well.  Either way, an allocation record will
4310 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
4311 number of minutes of usage applied to the call.
4312
4313 =cut
4314
4315 sub apply_usage {
4316   my ($self, %opt) = @_;
4317   my $cdr = $opt{cdr};
4318   my $rate_detail = $opt{rate_detail};
4319   my $minutes = $opt{minutes};
4320   my $classnum = $rate_detail->classnum;
4321   my $pkgnum = $self->pkgnum;
4322   my $custnum = $self->custnum;
4323
4324   local $SIG{HUP} = 'IGNORE';
4325   local $SIG{INT} = 'IGNORE'; 
4326   local $SIG{QUIT} = 'IGNORE';
4327   local $SIG{TERM} = 'IGNORE';
4328   local $SIG{TSTP} = 'IGNORE'; 
4329   local $SIG{PIPE} = 'IGNORE'; 
4330
4331   my $oldAutoCommit = $FS::UID::AutoCommit;
4332   local $FS::UID::AutoCommit = 0;
4333   my $dbh = dbh;
4334   my $order = FS::Conf->new->config('cdr-minutes_priority');
4335
4336   my $is_classnum;
4337   if ( $classnum ) {
4338     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
4339   } else {
4340     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
4341   }
4342   my @usage_recs = qsearch({
4343       'table'     => 'cust_pkg_usage',
4344       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
4345                      ' JOIN cust_pkg             USING (pkgnum)'.
4346                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
4347       'select'    => 'cust_pkg_usage.*',
4348       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
4349                      " ( cust_pkg.custnum = $custnum AND ".
4350                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4351                      $is_classnum . ' AND '.
4352                      " cust_pkg_usage.minutes > 0",
4353       'order_by'  => " ORDER BY priority ASC",
4354   });
4355
4356   my $orig_minutes = $minutes;
4357   my $error;
4358   while (!$error and $minutes > 0 and @usage_recs) {
4359     my $cust_pkg_usage = shift @usage_recs;
4360     $cust_pkg_usage->select_for_update;
4361     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
4362         pkgusagenum => $cust_pkg_usage->pkgusagenum,
4363         acctid      => $cdr->acctid,
4364         minutes     => min($cust_pkg_usage->minutes, $minutes),
4365     });
4366     $cust_pkg_usage->set('minutes',
4367       $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes
4368     );
4369     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
4370     $minutes -= $cdr_cust_pkg_usage->minutes;
4371   }
4372   if ( $order and $minutes > 0 and !$error ) {
4373     # then try to steal minutes from another call
4374     my %search = (
4375         'table'     => 'cdr_cust_pkg_usage',
4376         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
4377                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
4378                        ' JOIN cust_pkg              USING (pkgnum)'.
4379                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
4380                        ' JOIN cdr                   USING (acctid)',
4381         'select'    => 'cdr_cust_pkg_usage.*',
4382         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
4383                        " ( cust_pkg.pkgnum = $pkgnum OR ".
4384                        " ( cust_pkg.custnum = $custnum AND ".
4385                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4386                        " part_pkg_usage_class.classnum = $classnum",
4387         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
4388     );
4389     if ( $order eq 'time' ) {
4390       # find CDRs that are using minutes, but have a later startdate
4391       # than this call
4392       my $startdate = $cdr->startdate;
4393       if ($startdate !~ /^\d+$/) {
4394         die "bad cdr startdate '$startdate'";
4395       }
4396       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
4397       # minimize needless reshuffling
4398       $search{'order_by'} .= ', cdr.startdate DESC';
4399     } else {
4400       # XXX may not work correctly with rate_time schedules.  Could 
4401       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
4402       # think...
4403       $search{'addl_from'} .=
4404         ' JOIN rate_detail'.
4405         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4406       if ( $order eq 'rate_high' ) {
4407         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4408                                 $rate_detail->min_charge;
4409         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4410       } elsif ( $order eq 'rate_low' ) {
4411         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4412                                 $rate_detail->min_charge;
4413         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4414       } else {
4415         #  this should really never happen
4416         die "invalid cdr-minutes_priority value '$order'\n";
4417       }
4418     }
4419     my @cdr_usage_recs = qsearch(\%search);
4420     my %reproc_cdrs;
4421     while (!$error and @cdr_usage_recs and $minutes > 0) {
4422       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4423       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4424       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4425       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4426       $cdr_cust_pkg_usage->select_for_update;
4427       $old_cdr->select_for_update;
4428       $cust_pkg_usage->select_for_update;
4429       # in case someone else stole the usage from this CDR
4430       # while waiting for the lock...
4431       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4432       # steal the usage allocation and flag the old CDR for reprocessing
4433       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4434       # if the allocation is more minutes than we need, adjust it...
4435       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4436       if ( $delta > 0 ) {
4437         $cdr_cust_pkg_usage->set('minutes', $minutes);
4438         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4439         $error = $cust_pkg_usage->replace;
4440       }
4441       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4442       $error ||= $cdr_cust_pkg_usage->replace;
4443       # deduct the stolen minutes
4444       $minutes -= $cdr_cust_pkg_usage->minutes;
4445     }
4446     # after all minute-stealing is done, reset the affected CDRs
4447     foreach (values %reproc_cdrs) {
4448       $error ||= $_->set_status('');
4449       # XXX or should we just call $cdr->rate right here?
4450       # it's not like we can create a loop this way, since the min_charge
4451       # or call time has to go monotonically in one direction.
4452       # we COULD get some very deep recursions going, though...
4453     }
4454   } # if $order and $minutes
4455   if ( $error ) {
4456     $dbh->rollback;
4457     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4458   } else {
4459     $dbh->commit if $oldAutoCommit;
4460     return $orig_minutes - $minutes;
4461   }
4462 }
4463
4464 =item supplemental_pkgs
4465
4466 Returns a list of all packages supplemental to this one.
4467
4468 =cut
4469
4470 sub supplemental_pkgs {
4471   my $self = shift;
4472   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4473 }
4474
4475 =item main_pkg
4476
4477 Returns the package that this one is supplemental to, if any.
4478
4479 =cut
4480
4481 sub main_pkg {
4482   my $self = shift;
4483   if ( $self->main_pkgnum ) {
4484     return FS::cust_pkg->by_key($self->main_pkgnum);
4485   }
4486   return;
4487 }
4488
4489 =back
4490
4491 =head1 CLASS METHODS
4492
4493 =over 4
4494
4495 =item recurring_sql
4496
4497 Returns an SQL expression identifying recurring packages.
4498
4499 =cut
4500
4501 sub recurring_sql { "
4502   '0' != ( select freq from part_pkg
4503              where cust_pkg.pkgpart = part_pkg.pkgpart )
4504 "; }
4505
4506 =item onetime_sql
4507
4508 Returns an SQL expression identifying one-time packages.
4509
4510 =cut
4511
4512 sub onetime_sql { "
4513   '0' = ( select freq from part_pkg
4514             where cust_pkg.pkgpart = part_pkg.pkgpart )
4515 "; }
4516
4517 =item ordered_sql
4518
4519 Returns an SQL expression identifying ordered packages (recurring packages not
4520 yet billed).
4521
4522 =cut
4523
4524 sub ordered_sql {
4525    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4526 }
4527
4528 =item active_sql
4529
4530 Returns an SQL expression identifying active packages.
4531
4532 =cut
4533
4534 sub active_sql {
4535   $_[0]->recurring_sql. "
4536   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4537   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4538   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4539 "; }
4540
4541 =item not_yet_billed_sql
4542
4543 Returns an SQL expression identifying packages which have not yet been billed.
4544
4545 =cut
4546
4547 sub not_yet_billed_sql { "
4548       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4549   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4550   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4551 "; }
4552
4553 =item inactive_sql
4554
4555 Returns an SQL expression identifying inactive packages (one-time packages
4556 that are otherwise unsuspended/uncancelled).
4557
4558 =cut
4559
4560 sub inactive_sql { "
4561   ". $_[0]->onetime_sql(). "
4562   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4563   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4564   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4565 "; }
4566
4567 =item on_hold_sql
4568
4569 Returns an SQL expression identifying on-hold packages.
4570
4571 =cut
4572
4573 sub on_hold_sql {
4574   #$_[0]->recurring_sql(). ' AND '.
4575   "
4576         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel  = 0 )
4577     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp   != 0
4578     AND ( cust_pkg.setup  IS     NULL  OR cust_pkg.setup   = 0 )
4579   ";
4580 }
4581
4582 =item susp_sql
4583 =item suspended_sql
4584
4585 Returns an SQL expression identifying suspended packages.
4586
4587 =cut
4588
4589 sub suspended_sql { susp_sql(@_); }
4590 sub susp_sql {
4591   #$_[0]->recurring_sql(). ' AND '.
4592   "
4593         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4594     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4595     AND   cust_pkg.setup  IS NOT NULL AND cust_pkg.setup != 0
4596   ";
4597 }
4598
4599 =item cancel_sql
4600 =item cancelled_sql
4601
4602 Returns an SQL exprression identifying cancelled packages.
4603
4604 =cut
4605
4606 sub cancelled_sql { cancel_sql(@_); }
4607 sub cancel_sql { 
4608   #$_[0]->recurring_sql(). ' AND '.
4609   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4610 }
4611
4612 =item status_sql
4613
4614 Returns an SQL expression to give the package status as a string.
4615
4616 =cut
4617
4618 sub status_sql {
4619 "CASE
4620   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4621   WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold'
4622   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4623   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4624   WHEN ".onetime_sql()." THEN 'one-time charge'
4625   ELSE 'active'
4626 END"
4627 }
4628
4629 =item search HASHREF
4630
4631 (Class method)
4632
4633 Returns a qsearch hash expression to search for parameters specified in HASHREF.
4634 Valid parameters are
4635
4636 =over 4
4637
4638 =item agentnum
4639
4640 =item status
4641
4642 on hold, active, inactive (or one-time charge), suspended, canceled (or cancelled)
4643
4644 =item magic
4645
4646 Equivalent to "status", except that "canceled"/"cancelled" will exclude 
4647 packages that were changed into a new package with the same pkgpart (i.e.
4648 location or quantity changes).
4649
4650 =item custom
4651
4652  boolean selects custom packages
4653
4654 =item classnum
4655
4656 =item pkgpart
4657
4658 pkgpart or arrayref or hashref of pkgparts
4659
4660 =item setup
4661
4662 arrayref of beginning and ending epoch date
4663
4664 =item last_bill
4665
4666 arrayref of beginning and ending epoch date
4667
4668 =item bill
4669
4670 arrayref of beginning and ending epoch date
4671
4672 =item adjourn
4673
4674 arrayref of beginning and ending epoch date
4675
4676 =item susp
4677
4678 arrayref of beginning and ending epoch date
4679
4680 =item expire
4681
4682 arrayref of beginning and ending epoch date
4683
4684 =item cancel
4685
4686 arrayref of beginning and ending epoch date
4687
4688 =item query
4689
4690 pkgnum or APKG_pkgnum
4691
4692 =item cust_fields
4693
4694 a value suited to passing to FS::UI::Web::cust_header
4695
4696 =item CurrentUser
4697
4698 specifies the user for agent virtualization
4699
4700 =item fcc_line
4701
4702 boolean; if true, returns only packages with more than 0 FCC phone lines.
4703
4704 =item state, country
4705
4706 Limit to packages with a service location in the specified state and country.
4707 For FCC 477 reporting, mostly.
4708
4709 =item location_cust
4710
4711 Limit to packages whose service locations are the same as the customer's 
4712 default service location.
4713
4714 =item location_nocust
4715
4716 Limit to packages whose service locations are not the customer's default 
4717 service location.
4718
4719 =item location_census
4720
4721 Limit to packages whose service locations have census tracts.
4722
4723 =item location_nocensus
4724
4725 Limit to packages whose service locations do not have a census tract.
4726
4727 =item location_geocode
4728
4729 Limit to packages whose locations have geocodes.
4730
4731 =item location_geocode
4732
4733 Limit to packages whose locations do not have geocodes.
4734
4735 =item towernum
4736
4737 Limit to packages associated with a svc_broadband, associated with a sector,
4738 associated with this towernum (or any of these, if it's an arrayref) (or NO
4739 towernum, if it's zero). This is an extreme niche case.
4740
4741 =item 477part, 477rownum, date
4742
4743 Limit to packages included in a specific row of one of the FCC 477 reports.
4744 '477part' is the section name (see L<FS::Report::FCC_477> methods), 'date'
4745 is the report as-of date (completely unrelated to the package setup/bill/
4746 other date fields), and '477rownum' is the row number of the report starting
4747 with zero. Row numbers have no inherent meaning, so this is useful only 
4748 for explaining a 477 report you've already run.
4749
4750 =back
4751
4752 =cut
4753
4754 sub search {
4755   my ($class, $params) = @_;
4756   my @where = ();
4757
4758   ##
4759   # parse agent
4760   ##
4761
4762   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
4763     push @where,
4764       "cust_main.agentnum = $1";
4765   }
4766
4767   ##
4768   # parse cust_status
4769   ##
4770
4771   if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
4772     push @where, FS::cust_main->cust_status_sql . " = '$1' ";
4773   }
4774
4775   ##
4776   # parse customer sales person
4777   ##
4778
4779   if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
4780     push @where, ($1 > 0) ? "cust_main.salesnum = $1"
4781                           : 'cust_main.salesnum IS NULL';
4782   }
4783
4784
4785   ##
4786   # parse sales person
4787   ##
4788
4789   if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
4790     push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
4791                           : 'cust_pkg.salesnum IS NULL';
4792   }
4793
4794   ##
4795   # parse custnum
4796   ##
4797
4798   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
4799     push @where,
4800       "cust_pkg.custnum = $1";
4801   }
4802
4803   ##
4804   # custbatch
4805   ##
4806
4807   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
4808     push @where,
4809       "cust_pkg.pkgbatch = '$1'";
4810   }
4811
4812   ##
4813   # parse status
4814   ##
4815
4816   if (    $params->{'magic'}  eq 'active'
4817        || $params->{'status'} eq 'active' ) {
4818
4819     push @where, FS::cust_pkg->active_sql();
4820
4821   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
4822             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
4823
4824     push @where, FS::cust_pkg->not_yet_billed_sql();
4825
4826   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
4827             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
4828
4829     push @where, FS::cust_pkg->inactive_sql();
4830
4831   } elsif (    $params->{'magic'}  =~ /^on[ _]hold$/
4832             || $params->{'status'} =~ /^on[ _]hold$/ ) {
4833
4834     push @where, FS::cust_pkg->on_hold_sql();
4835
4836
4837   } elsif (    $params->{'magic'}  eq 'suspended'
4838             || $params->{'status'} eq 'suspended'  ) {
4839
4840     push @where, FS::cust_pkg->suspended_sql();
4841
4842   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
4843             || $params->{'status'} =~ /^cancell?ed$/ ) {
4844
4845     push @where, FS::cust_pkg->cancelled_sql();
4846
4847   }
4848   
4849   ### special case: "magic" is used in detail links from browse/part_pkg,
4850   # where "cancelled" has the restriction "and not replaced with a package
4851   # of the same pkgpart".  Be consistent with that.
4852   ###
4853
4854   if ( $params->{'magic'} =~ /^cancell?ed$/ ) {
4855     my $new_pkgpart = "SELECT pkgpart FROM cust_pkg AS cust_pkg_next ".
4856                       "WHERE cust_pkg_next.change_pkgnum = cust_pkg.pkgnum";
4857     # ...may not exist, if this was just canceled and not changed; in that
4858     # case give it a "new pkgpart" that never equals the old pkgpart
4859     push @where, "COALESCE(($new_pkgpart), 0) != cust_pkg.pkgpart";
4860   }
4861
4862   ###
4863   # parse package class
4864   ###
4865
4866   if ( exists($params->{'classnum'}) ) {
4867
4868     my @classnum = ();
4869     if ( ref($params->{'classnum'}) ) {
4870
4871       if ( ref($params->{'classnum'}) eq 'HASH' ) {
4872         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
4873       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
4874         @classnum = @{ $params->{'classnum'} };
4875       } else {
4876         die 'unhandled classnum ref '. $params->{'classnum'};
4877       }
4878
4879
4880     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
4881       @classnum = ( $1 );
4882     }
4883
4884     if ( @classnum ) {
4885
4886       my @c_where = ();
4887       my @nums = grep $_, @classnum;
4888       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
4889       my $null = scalar( grep { $_ eq '' } @classnum );
4890       push @c_where, 'part_pkg.classnum IS NULL' if $null;
4891
4892       if ( scalar(@c_where) == 1 ) {
4893         push @where, @c_where;
4894       } elsif ( @c_where ) {
4895         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
4896       }
4897
4898     }
4899     
4900
4901   }
4902
4903   ###
4904   # parse refnum (advertising source)
4905   ###
4906
4907   if ( exists($params->{'refnum'}) ) {
4908     my @refnum;
4909     if (ref $params->{'refnum'}) {
4910       @refnum = @{ $params->{'refnum'} };
4911     } else {
4912       @refnum = ( $params->{'refnum'} );
4913     }
4914     my $in = join(',', grep /^\d+$/, @refnum);
4915     push @where, "refnum IN($in)" if length $in;
4916   }
4917
4918   ###
4919   # parse package report options
4920   ###
4921
4922   my @report_option = ();
4923   if ( exists($params->{'report_option'}) ) {
4924     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
4925       @report_option = @{ $params->{'report_option'} };
4926     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
4927       @report_option = split(',', $1);
4928     }
4929
4930   }
4931
4932   if (@report_option) {
4933     # this will result in the empty set for the dangling comma case as it should
4934     push @where, 
4935       map{ "0 < ( SELECT count(*) FROM part_pkg_option
4936                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4937                     AND optionname = 'report_option_$_'
4938                     AND optionvalue = '1' )"
4939          } @report_option;
4940   }
4941
4942   foreach my $any ( grep /^report_option_any/, keys %$params ) {
4943
4944     my @report_option_any = ();
4945     if ( ref($params->{$any}) eq 'ARRAY' ) {
4946       @report_option_any = @{ $params->{$any} };
4947     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
4948       @report_option_any = split(',', $1);
4949     }
4950
4951     if (@report_option_any) {
4952       # this will result in the empty set for the dangling comma case as it should
4953       push @where, ' ( '. join(' OR ',
4954         map{ "0 < ( SELECT count(*) FROM part_pkg_option
4955                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
4956                       AND optionname = 'report_option_$_'
4957                       AND optionvalue = '1' )"
4958            } @report_option_any
4959       ). ' ) ';
4960     }
4961
4962   }
4963
4964   ###
4965   # parse custom
4966   ###
4967
4968   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
4969
4970   ###
4971   # parse fcc_line
4972   ###
4973
4974   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
4975                                                         if $params->{fcc_line};
4976
4977   ###
4978   # parse censustract
4979   ###
4980
4981   if ( exists($params->{'censustract'}) ) {
4982     $params->{'censustract'} =~ /^([.\d]*)$/;
4983     my $censustract = "cust_location.censustract = '$1'";
4984     $censustract .= ' OR cust_location.censustract is NULL' unless $1;
4985     push @where,  "( $censustract )";
4986   }
4987
4988   ###
4989   # parse censustract2
4990   ###
4991   if ( exists($params->{'censustract2'})
4992        && $params->{'censustract2'} =~ /^(\d*)$/
4993      )
4994   {
4995     if ($1) {
4996       push @where, "cust_location.censustract LIKE '$1%'";
4997     } else {
4998       push @where,
4999         "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
5000     }
5001   }
5002
5003   ###
5004   # parse country/state/zip
5005   ###
5006   for (qw(state country)) { # parsing rules are the same for these
5007   if ( exists($params->{$_}) 
5008     && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
5009     {
5010       # XXX post-2.3 only--before that, state/country may be in cust_main
5011       push @where, "cust_location.$_ = '$1'";
5012     }
5013   }
5014   if ( exists($params->{zip}) ) {
5015     push @where, "cust_location.zip = " . dbh->quote($params->{zip});
5016   }
5017
5018   ###
5019   # location_* flags
5020   ###
5021   if ( $params->{location_cust} xor $params->{location_nocust} ) {
5022     my $op = $params->{location_cust} ? '=' : '!=';
5023     push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
5024   }
5025   if ( $params->{location_census} xor $params->{location_nocensus} ) {
5026     my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
5027     push @where, "cust_location.censustract $op";
5028   }
5029   if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
5030     my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
5031     push @where, "cust_location.geocode $op";
5032   }
5033
5034   ###
5035   # parse part_pkg
5036   ###
5037
5038   if ( ref($params->{'pkgpart'}) ) {
5039
5040     my @pkgpart = ();
5041     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
5042       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
5043     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
5044       @pkgpart = @{ $params->{'pkgpart'} };
5045     } else {
5046       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
5047     }
5048
5049     @pkgpart = grep /^(\d+)$/, @pkgpart;
5050
5051     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
5052
5053   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
5054     push @where, "pkgpart = $1";
5055   } 
5056
5057   ###
5058   # parse dates
5059   ###
5060
5061   my $orderby = '';
5062
5063   #false laziness w/report_cust_pkg.html
5064   my %disable = (
5065     'all'             => {},
5066     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
5067     'active'          => { 'susp'=>1, 'cancel'=>1 },
5068     'suspended'       => { 'cancel' => 1 },
5069     'cancelled'       => {},
5070     ''                => {},
5071   );
5072
5073   if( exists($params->{'active'} ) ) {
5074     # This overrides all the other date-related fields, and includes packages
5075     # that were active at some time during the interval.  It excludes:
5076     # - packages that were set up after the end of the interval
5077     # - packages that were canceled before the start of the interval
5078     # - packages that were suspended before the start of the interval
5079     #   and are still suspended now
5080     my($beginning, $ending) = @{$params->{'active'}};
5081     push @where,
5082       "cust_pkg.setup IS NOT NULL",
5083       "cust_pkg.setup <= $ending",
5084       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
5085       "(cust_pkg.susp   IS NULL OR cust_pkg.susp   >= $beginning )",
5086       "NOT (".FS::cust_pkg->onetime_sql . ")";
5087   }
5088   else {
5089     my $exclude_change_from = 0;
5090     my $exclude_change_to = 0;
5091
5092     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
5093
5094       next unless exists($params->{$field});
5095
5096       my($beginning, $ending) = @{$params->{$field}};
5097
5098       next if $beginning == 0 && $ending == 4294967295;
5099
5100       push @where,
5101         "cust_pkg.$field IS NOT NULL",
5102         "cust_pkg.$field >= $beginning",
5103         "cust_pkg.$field <= $ending";
5104
5105       $orderby ||= "ORDER BY cust_pkg.$field";
5106
5107       if ( $field eq 'setup' ) {
5108         $exclude_change_from = 1;
5109       } elsif ( $field eq 'cancel' ) {
5110         $exclude_change_to = 1;
5111       } elsif ( $field eq 'change_date' ) {
5112         # if we are given setup and change_date ranges, and the setup date
5113         # falls in _both_ ranges, then include the package whether it was 
5114         # a change or not
5115         $exclude_change_from = 0;
5116       }
5117     }
5118
5119     if ($exclude_change_from) {
5120       push @where, "change_pkgnum IS NULL";
5121     }
5122     if ($exclude_change_to) {
5123       # a join might be more efficient here
5124       push @where, "NOT EXISTS(
5125         SELECT 1 FROM cust_pkg AS changed_to_pkg
5126         WHERE cust_pkg.pkgnum = changed_to_pkg.change_pkgnum
5127       )";
5128     }
5129   }
5130
5131   $orderby ||= 'ORDER BY bill';
5132
5133   ###
5134   # parse magic, legacy, etc.
5135   ###
5136
5137   if ( $params->{'magic'} &&
5138        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
5139   ) {
5140
5141     $orderby = 'ORDER BY pkgnum';
5142
5143     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
5144       push @where, "pkgpart = $1";
5145     }
5146
5147   } elsif ( $params->{'query'} eq 'pkgnum' ) {
5148
5149     $orderby = 'ORDER BY pkgnum';
5150
5151   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
5152
5153     $orderby = 'ORDER BY pkgnum';
5154
5155     push @where, '0 < (
5156       SELECT count(*) FROM pkg_svc
5157        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
5158          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
5159                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
5160                                      AND cust_svc.svcpart = pkg_svc.svcpart
5161                                 )
5162     )';
5163   
5164   }
5165
5166   ##
5167   # parse the extremely weird 'towernum' param
5168   ##
5169
5170   if ($params->{towernum}) {
5171     my $towernum = $params->{towernum};
5172     $towernum = [ $towernum ] if !ref($towernum);
5173     my $in = join(',', grep /^\d+$/, @$towernum);
5174     if (length $in) {
5175       # inefficient, but this is an obscure feature
5176       eval "use FS::Report::Table";
5177       FS::Report::Table->_init_tower_pkg_cache; # probably does nothing
5178       push @where, "EXISTS(
5179       SELECT 1 FROM tower_pkg_cache
5180       WHERE tower_pkg_cache.pkgnum = cust_pkg.pkgnum
5181         AND tower_pkg_cache.towernum IN ($in)
5182       )"
5183     }
5184   }
5185
5186   ##
5187   # parse the 477 report drill-down options
5188   ##
5189
5190   if ($params->{'477part'} =~ /^([a-z]+)$/) {
5191     my $section = $1;
5192     my ($date, $rownum, $agentnum);
5193     if ($params->{'date'} =~ /^(\d+)$/) {
5194       $date = $1;
5195     }
5196     if ($params->{'477rownum'} =~ /^(\d+)$/) {
5197       $rownum = $1;
5198     }
5199     if ($params->{'agentnum'} =~ /^(\d+)$/) {
5200       $agentnum = $1;
5201     }
5202     if ($date and defined($rownum)) {
5203       my $report = FS::Report::FCC_477->report($section,
5204         'date'      => $date,
5205         'agentnum'  => $agentnum,
5206         'detail'    => 1
5207       );
5208       my $pkgnums = $report->{detail}->[$rownum]
5209         or die "row $rownum is past the end of the report";
5210         # '0' so that if there are no pkgnums (empty string) it will create
5211         # a valid query that returns nothing
5212       warn "PKGNUMS:\n$pkgnums\n\n"; # XXX debug
5213
5214       # and this overrides everything
5215       @where = ( "cust_pkg.pkgnum IN($pkgnums)" );
5216     } # else we're missing some params, ignore the whole business
5217   }
5218
5219   ##
5220   # setup queries, links, subs, etc. for the search
5221   ##
5222
5223   # here is the agent virtualization
5224   if ($params->{CurrentUser}) {
5225     my $access_user =
5226       qsearchs('access_user', { username => $params->{CurrentUser} });
5227
5228     if ($access_user) {
5229       push @where, $access_user->agentnums_sql('table'=>'cust_main');
5230     } else {
5231       push @where, "1=0";
5232     }
5233   } else {
5234     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
5235   }
5236
5237   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
5238
5239   my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
5240                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
5241                   'LEFT JOIN cust_location USING ( locationnum ) '.
5242                   FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
5243
5244   my $select;
5245   my $count_query;
5246   if ( $params->{'select_zip5'} ) {
5247     my $zip = 'cust_location.zip';
5248
5249     $select = "DISTINCT substr($zip,1,5) as zip";
5250     $orderby = "ORDER BY substr($zip,1,5)";
5251     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
5252   } else {
5253     $select = join(', ',
5254                          'cust_pkg.*',
5255                          ( map "part_pkg.$_", qw( pkg freq ) ),
5256                          'pkg_class.classname',
5257                          'cust_main.custnum AS cust_main_custnum',
5258                          FS::UI::Web::cust_sql_fields(
5259                            $params->{'cust_fields'}
5260                          ),
5261                   );
5262     $count_query = 'SELECT COUNT(*)';
5263   }
5264
5265   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
5266
5267   my $sql_query = {
5268     'table'       => 'cust_pkg',
5269     'hashref'     => {},
5270     'select'      => $select,
5271     'extra_sql'   => $extra_sql,
5272     'order_by'    => $orderby,
5273     'addl_from'   => $addl_from,
5274     'count_query' => $count_query,
5275   };
5276
5277 }
5278
5279 =item fcc_477_count
5280
5281 Returns a list of two package counts.  The first is a count of packages
5282 based on the supplied criteria and the second is the count of residential
5283 packages with those same criteria.  Criteria are specified as in the search
5284 method.
5285
5286 =cut
5287
5288 sub fcc_477_count {
5289   my ($class, $params) = @_;
5290
5291   my $sql_query = $class->search( $params );
5292
5293   my $count_sql = delete($sql_query->{'count_query'});
5294   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
5295     or die "couldn't parse count_sql";
5296
5297   my $count_sth = dbh->prepare($count_sql)
5298     or die "Error preparing $count_sql: ". dbh->errstr;
5299   $count_sth->execute
5300     or die "Error executing $count_sql: ". $count_sth->errstr;
5301   my $count_arrayref = $count_sth->fetchrow_arrayref;
5302
5303   return ( @$count_arrayref );
5304
5305 }
5306
5307 =item tax_locationnum_sql
5308
5309 Returns an SQL expression for the tax location for a package, based
5310 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
5311
5312 =cut
5313
5314 sub tax_locationnum_sql {
5315   my $conf = FS::Conf->new;
5316   if ( $conf->exists('tax-pkg_address') ) {
5317     'cust_pkg.locationnum';
5318   }
5319   elsif ( $conf->exists('tax-ship_address') ) {
5320     'cust_main.ship_locationnum';
5321   }
5322   else {
5323     'cust_main.bill_locationnum';
5324   }
5325 }
5326
5327 =item location_sql
5328
5329 Returns a list: the first item is an SQL fragment identifying matching 
5330 packages/customers via location (taking into account shipping and package
5331 address taxation, if enabled), and subsequent items are the parameters to
5332 substitute for the placeholders in that fragment.
5333
5334 =cut
5335
5336 sub location_sql {
5337   my($class, %opt) = @_;
5338   my $ornull = $opt{'ornull'};
5339
5340   my $conf = new FS::Conf;
5341
5342   # '?' placeholders in _location_sql_where
5343   my $x = $ornull ? 3 : 2;
5344   my @bill_param = ( 
5345     ('district')x3,
5346     ('city')x3, 
5347     ('county')x$x,
5348     ('state')x$x,
5349     'country'
5350   );
5351
5352   my $main_where;
5353   my @main_param;
5354   if ( $conf->exists('tax-ship_address') ) {
5355
5356     $main_where = "(
5357          (     ( ship_last IS NULL     OR  ship_last  = '' )
5358            AND ". _location_sql_where('cust_main', '', $ornull ). "
5359          )
5360       OR (       ship_last IS NOT NULL AND ship_last != ''
5361            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
5362          )
5363     )";
5364     #    AND payby != 'COMP'
5365
5366     @main_param = ( @bill_param, @bill_param );
5367
5368   } else {
5369
5370     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
5371     @main_param = @bill_param;
5372
5373   }
5374
5375   my $where;
5376   my @param;
5377   if ( $conf->exists('tax-pkg_address') ) {
5378
5379     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
5380
5381     $where = " (
5382                     ( cust_pkg.locationnum IS     NULL AND $main_where )
5383                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
5384                )
5385              ";
5386     @param = ( @main_param, @bill_param );
5387   
5388   } else {
5389
5390     $where = $main_where;
5391     @param = @main_param;
5392
5393   }
5394
5395   ( $where, @param );
5396
5397 }
5398
5399 #subroutine, helper for location_sql
5400 sub _location_sql_where {
5401   my $table  = shift;
5402   my $prefix = @_ ? shift : '';
5403   my $ornull = @_ ? shift : '';
5404
5405 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
5406
5407   $ornull = $ornull ? ' OR ? IS NULL ' : '';
5408
5409   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
5410   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
5411   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
5412
5413   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
5414
5415 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
5416   "
5417         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
5418     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
5419     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
5420     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
5421     AND   $table.${prefix}country  = ?
5422   ";
5423 }
5424
5425 sub _X_show_zero {
5426   my( $self, $what ) = @_;
5427
5428   my $what_show_zero = $what. '_show_zero';
5429   length($self->$what_show_zero())
5430     ? ($self->$what_show_zero() eq 'Y')
5431     : $self->part_pkg->$what_show_zero();
5432 }
5433
5434 =head1 SUBROUTINES
5435
5436 =over 4
5437
5438 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
5439
5440 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
5441 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
5442
5443 CUSTNUM is a customer (see L<FS::cust_main>)
5444
5445 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5446 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
5447 permitted.
5448
5449 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
5450 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
5451 new billing items.  An error is returned if this is not possible (see
5452 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
5453 parameter.
5454
5455 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5456 newly-created cust_pkg objects.
5457
5458 REFNUM, if specified, will specify the FS::pkg_referral record to be created
5459 and inserted.  Multiple FS::pkg_referral records can be created by
5460 setting I<refnum> to an array reference of refnums or a hash reference with
5461 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
5462 record will be created corresponding to cust_main.refnum.
5463
5464 =cut
5465
5466 sub order {
5467   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
5468
5469   my $conf = new FS::Conf;
5470
5471   # Transactionize this whole mess
5472   local $SIG{HUP} = 'IGNORE';
5473   local $SIG{INT} = 'IGNORE'; 
5474   local $SIG{QUIT} = 'IGNORE';
5475   local $SIG{TERM} = 'IGNORE';
5476   local $SIG{TSTP} = 'IGNORE'; 
5477   local $SIG{PIPE} = 'IGNORE'; 
5478
5479   my $oldAutoCommit = $FS::UID::AutoCommit;
5480   local $FS::UID::AutoCommit = 0;
5481   my $dbh = dbh;
5482
5483   my $error;
5484 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
5485 #  return "Customer not found: $custnum" unless $cust_main;
5486
5487   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
5488     if $DEBUG;
5489
5490   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5491                          @$remove_pkgnum;
5492
5493   my $change = scalar(@old_cust_pkg) != 0;
5494
5495   my %hash = (); 
5496   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
5497
5498     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
5499          " to pkgpart ". $pkgparts->[0]. "\n"
5500       if $DEBUG;
5501
5502     my $err_or_cust_pkg =
5503       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
5504                                 'refnum'  => $refnum,
5505                               );
5506
5507     unless (ref($err_or_cust_pkg)) {
5508       $dbh->rollback if $oldAutoCommit;
5509       return $err_or_cust_pkg;
5510     }
5511
5512     push @$return_cust_pkg, $err_or_cust_pkg;
5513     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5514     return '';
5515
5516   }
5517
5518   # Create the new packages.
5519   foreach my $pkgpart (@$pkgparts) {
5520
5521     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
5522
5523     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
5524                                       pkgpart => $pkgpart,
5525                                       refnum  => $refnum,
5526                                       %hash,
5527                                     };
5528     $error = $cust_pkg->insert( 'change' => $change );
5529     push @$return_cust_pkg, $cust_pkg;
5530
5531     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
5532       my $supp_pkg = FS::cust_pkg->new({
5533           custnum => $custnum,
5534           pkgpart => $link->dst_pkgpart,
5535           refnum  => $refnum,
5536           main_pkgnum => $cust_pkg->pkgnum,
5537           %hash,
5538       });
5539       $error ||= $supp_pkg->insert( 'change' => $change );
5540       push @$return_cust_pkg, $supp_pkg;
5541     }
5542
5543     if ($error) {
5544       $dbh->rollback if $oldAutoCommit;
5545       return $error;
5546     }
5547
5548   }
5549   # $return_cust_pkg now contains refs to all of the newly 
5550   # created packages.
5551
5552   # Transfer services and cancel old packages.
5553   foreach my $old_pkg (@old_cust_pkg) {
5554
5555     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
5556       if $DEBUG;
5557
5558     foreach my $new_pkg (@$return_cust_pkg) {
5559       $error = $old_pkg->transfer($new_pkg);
5560       if ($error and $error == 0) {
5561         # $old_pkg->transfer failed.
5562         $dbh->rollback if $oldAutoCommit;
5563         return $error;
5564       }
5565     }
5566
5567     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
5568       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
5569       foreach my $new_pkg (@$return_cust_pkg) {
5570         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
5571         if ($error and $error == 0) {
5572           # $old_pkg->transfer failed.
5573         $dbh->rollback if $oldAutoCommit;
5574         return $error;
5575         }
5576       }
5577     }
5578
5579     if ($error > 0) {
5580       # Transfers were successful, but we went through all of the 
5581       # new packages and still had services left on the old package.
5582       # We can't cancel the package under the circumstances, so abort.
5583       $dbh->rollback if $oldAutoCommit;
5584       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
5585     }
5586     $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 );
5587     if ($error) {
5588       $dbh->rollback;
5589       return $error;
5590     }
5591   }
5592   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5593   '';
5594 }
5595
5596 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
5597
5598 A bulk change method to change packages for multiple customers.
5599
5600 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5601 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
5602 permitted.
5603
5604 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5605 replace.  The services (see L<FS::cust_svc>) are moved to the
5606 new billing items.  An error is returned if this is not possible (see
5607 L<FS::pkg_svc>).
5608
5609 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5610 newly-created cust_pkg objects.
5611
5612 =cut
5613
5614 sub bulk_change {
5615   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5616
5617   # Transactionize this whole mess
5618   local $SIG{HUP} = 'IGNORE';
5619   local $SIG{INT} = 'IGNORE'; 
5620   local $SIG{QUIT} = 'IGNORE';
5621   local $SIG{TERM} = 'IGNORE';
5622   local $SIG{TSTP} = 'IGNORE'; 
5623   local $SIG{PIPE} = 'IGNORE'; 
5624
5625   my $oldAutoCommit = $FS::UID::AutoCommit;
5626   local $FS::UID::AutoCommit = 0;
5627   my $dbh = dbh;
5628
5629   my @errors;
5630   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5631                          @$remove_pkgnum;
5632
5633   while(scalar(@old_cust_pkg)) {
5634     my @return = ();
5635     my $custnum = $old_cust_pkg[0]->custnum;
5636     my (@remove) = map { $_->pkgnum }
5637                    grep { $_->custnum == $custnum } @old_cust_pkg;
5638     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5639
5640     my $error = order $custnum, $pkgparts, \@remove, \@return;
5641
5642     push @errors, $error
5643       if $error;
5644     push @$return_cust_pkg, @return;
5645   }
5646
5647   if (scalar(@errors)) {
5648     $dbh->rollback if $oldAutoCommit;
5649     return join(' / ', @errors);
5650   }
5651
5652   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5653   '';
5654 }
5655
5656 # Used by FS::Upgrade to migrate to a new database.
5657 sub _upgrade_data {  # class method
5658   my ($class, %opts) = @_;
5659   $class->_upgrade_otaker(%opts);
5660   my @statements = (
5661     # RT#10139, bug resulting in contract_end being set when it shouldn't
5662   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5663     # RT#10830, bad calculation of prorate date near end of year
5664     # the date range for bill is December 2009, and we move it forward
5665     # one year if it's before the previous bill date (which it should 
5666     # never be)
5667   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5668   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5669   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5670     # RT6628, add order_date to cust_pkg
5671     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5672         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5673         history_action = \'insert\') where order_date is null',
5674   );
5675   foreach my $sql (@statements) {
5676     my $sth = dbh->prepare($sql);
5677     $sth->execute or die $sth->errstr;
5678   }
5679
5680   # RT31194: supplemental package links that are deleted don't clean up 
5681   # linked records
5682   my @pkglinknums = qsearch({
5683       'select'    => 'DISTINCT cust_pkg.pkglinknum',
5684       'table'     => 'cust_pkg',
5685       'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ',
5686       'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL 
5687                         AND part_pkg_link.pkglinknum IS NULL',
5688   });
5689   foreach (@pkglinknums) {
5690     my $pkglinknum = $_->pkglinknum;
5691     warn "cleaning part_pkg_link #$pkglinknum\n";
5692     my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum});
5693     my $error = $part_pkg_link->remove_linked;
5694     die $error if $error;
5695   }
5696 }
5697
5698 =back
5699
5700 =head1 BUGS
5701
5702 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5703
5704 In sub order, the @pkgparts array (passed by reference) is clobbered.
5705
5706 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5707 method to pass dates to the recur_prog expression, it should do so.
5708
5709 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5710 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5711 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5712 configuration values.  Probably need a subroutine which decides what to do
5713 based on whether or not we've fetched the user yet, rather than a hash.  See
5714 FS::UID and the TODO.
5715
5716 Now that things are transactional should the check in the insert method be
5717 moved to check ?
5718
5719 =head1 SEE ALSO
5720
5721 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5722 L<FS::pkg_svc>, schema.html from the base documentation
5723
5724 =cut
5725
5726 1;
5727