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