2061c2cc192ee8a1506df4f248b9a772cf86a90d
[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     'select'    => 'cust_svc.*, part_svc.*',
3211     'table'     => 'cust_svc',
3212     'hashref'   => { 'pkgnum' => $self->pkgnum },
3213     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )',
3214   );
3215   $search{hashref}->{svcpart} = $opt{svcpart}
3216     if $opt{svcpart};
3217   $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{svcdb} )
3218     if $opt{svcdb};
3219
3220   [ qsearch(\%search) ];
3221
3222 }
3223
3224 =item overlimit [ SVCPART ]
3225
3226 Returns the services for this package which have exceeded their
3227 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
3228 is specified, return only the matching services.
3229
3230 =cut
3231
3232 sub overlimit {
3233   my $self = shift;
3234   return () unless $self->num_cust_svc(@_);
3235   grep { $_->overlimit } $self->cust_svc(@_);
3236 }
3237
3238 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3239
3240 Returns historical services for this package created before END TIMESTAMP and
3241 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
3242 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
3243 I<pkg_svc.hidden> flag will be omitted.
3244
3245 =cut
3246
3247 sub h_cust_svc {
3248   my $self = shift;
3249   warn "$me _h_cust_svc called on $self\n"
3250     if $DEBUG;
3251
3252   my ($end, $start, $mode) = @_;
3253   my @cust_svc = $self->_sort_cust_svc(
3254     [ qsearch( 'h_cust_svc',
3255       { 'pkgnum' => $self->pkgnum, },  
3256       FS::h_cust_svc->sql_h_search(@_),  
3257     ) ]
3258   );
3259   if ( defined($mode) && $mode eq 'I' ) {
3260     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
3261     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
3262   } else {
3263     return @cust_svc;
3264   }
3265 }
3266
3267 sub _sort_cust_svc {
3268   my( $self, $arrayref ) = @_;
3269
3270   my $sort =
3271     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
3272
3273   my %pkg_svc = map { $_->svcpart => $_ }
3274                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
3275
3276   map  { $_->[0] }
3277   sort $sort
3278   map {
3279         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
3280         [ $_,
3281           $pkg_svc ? $pkg_svc->primary_svc : '',
3282           $pkg_svc ? $pkg_svc->quantity : 0,
3283         ];
3284       }
3285   @$arrayref;
3286
3287 }
3288
3289 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
3290
3291 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
3292
3293 Returns the number of services for this package.  Available options are svcpart
3294 and svcdb.  If either is spcififed, returns only the matching services.
3295
3296 =cut
3297
3298 sub num_cust_svc {
3299   my $self = shift;
3300
3301   return $self->{'_num_cust_svc'}
3302     if !scalar(@_)
3303        && exists($self->{'_num_cust_svc'})
3304        && $self->{'_num_cust_svc'} =~ /\d/;
3305
3306   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
3307     if $DEBUG > 2;
3308
3309   my %opt = ();
3310   if ( @_ && $_[0] =~ /^\d+/ ) {
3311     $opt{svcpart} = shift;
3312   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3313     %opt = %{ $_[0] };
3314   } elsif ( @_ ) {
3315     %opt = @_;
3316   }
3317
3318   my $select = 'SELECT COUNT(*) FROM cust_svc ';
3319   my $where = ' WHERE pkgnum = ? ';
3320   my @param = ($self->pkgnum);
3321
3322   if ( $opt{'svcpart'} ) {
3323     $where .= ' AND svcpart = ? ';
3324     push @param, $opt{'svcpart'};
3325   }
3326   if ( $opt{'svcdb'} ) {
3327     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
3328     $where .= ' AND svcdb = ? ';
3329     push @param, $opt{'svcdb'};
3330   }
3331
3332   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
3333   $sth->execute(@param) or die $sth->errstr;
3334   $sth->fetchrow_arrayref->[0];
3335 }
3336
3337 =item available_part_svc 
3338
3339 Returns a list of FS::part_svc objects representing services included in this
3340 package but not yet provisioned.  Each FS::part_svc object also has an extra
3341 field, I<num_avail>, which specifies the number of available services.
3342
3343 Accepts option I<provision_hold>;  if true, only returns part_svc for which the
3344 associated pkg_svc has the provision_hold flag set.
3345
3346 =cut
3347
3348 sub available_part_svc {
3349   my $self = shift;
3350   my %opt  = @_;
3351
3352   my $pkg_quantity = $self->quantity || 1;
3353
3354   grep { $_->num_avail > 0 }
3355   map {
3356     my $part_svc = $_->part_svc;
3357     $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
3358     $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
3359
3360     # more evil encapsulation breakage
3361     if ($part_svc->{'Hash'}{'num_avail'} > 0) {
3362       my @exports = $part_svc->part_export_did;
3363       $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
3364         }
3365
3366     $part_svc;
3367   }
3368   grep { $opt{'provision_hold'} ? $_->provision_hold : 1 }
3369   $self->part_pkg->pkg_svc;
3370 }
3371
3372 =item part_svc [ OPTION => VALUE ... ]
3373
3374 Returns a list of FS::part_svc objects representing provisioned and available
3375 services included in this package.  Each FS::part_svc object also has the
3376 following extra fields:
3377
3378 =over 4
3379
3380 =item num_cust_svc
3381
3382 (count)
3383
3384 =item num_avail
3385
3386 (quantity - count)
3387
3388 =item cust_pkg_svc
3389
3390 (services) - array reference containing the provisioned services, as cust_svc objects
3391
3392 =back
3393
3394 Accepts two options:
3395
3396 =over 4
3397
3398 =item summarize_size
3399
3400 If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc
3401 is this size or greater.
3402
3403 =item hide_discontinued
3404
3405 If true, will omit looking for services that are no longer avaialble in the
3406 package definition.
3407
3408 =back
3409
3410 =cut
3411
3412 #svcnum
3413 #label -> ($cust_svc->label)[1]
3414
3415 sub part_svc {
3416   my $self = shift;
3417   my %opt = @_;
3418
3419   my $pkg_quantity = $self->quantity || 1;
3420
3421   #XXX some sort of sort order besides numeric by svcpart...
3422   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
3423     my $pkg_svc = $_;
3424     my $part_svc = $pkg_svc->part_svc;
3425     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3426     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
3427     $part_svc->{'Hash'}{'num_avail'}    =
3428       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
3429     $part_svc->{'Hash'}{'cust_pkg_svc'} =
3430         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
3431       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
3432           && $num_cust_svc >= $opt{summarize_size};
3433     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
3434     $part_svc;
3435   } $self->part_pkg->pkg_svc;
3436
3437   unless ( $opt{hide_discontinued} ) {
3438     #extras
3439     push @part_svc, map {
3440       my $part_svc = $_;
3441       my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3442       $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
3443       $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
3444       $part_svc->{'Hash'}{'cust_pkg_svc'} =
3445         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
3446       $part_svc;
3447     } $self->extra_part_svc;
3448   }
3449
3450   @part_svc;
3451
3452 }
3453
3454 =item extra_part_svc
3455
3456 Returns a list of FS::part_svc objects corresponding to services in this
3457 package which are still provisioned but not (any longer) available in the
3458 package definition.
3459
3460 =cut
3461
3462 sub extra_part_svc {
3463   my $self = shift;
3464
3465   my $pkgnum  = $self->pkgnum;
3466   #my $pkgpart = $self->pkgpart;
3467
3468 #  qsearch( {
3469 #    'table'     => 'part_svc',
3470 #    'hashref'   => {},
3471 #    'extra_sql' =>
3472 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
3473 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
3474 #                       AND pkg_svc.pkgpart = ?
3475 #                       AND quantity > 0 
3476 #                 )
3477 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
3478 #                       LEFT JOIN cust_pkg USING ( pkgnum )
3479 #                     WHERE cust_svc.svcpart = part_svc.svcpart
3480 #                       AND pkgnum = ?
3481 #                 )",
3482 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
3483 #  } );
3484
3485 #seems to benchmark slightly faster... (or did?)
3486
3487   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
3488   my $pkgparts = join(',', @pkgparts);
3489
3490   qsearch( {
3491     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
3492     #MySQL doesn't grok DISINCT ON
3493     'select'      => 'DISTINCT part_svc.*',
3494     'table'       => 'part_svc',
3495     'addl_from'   =>
3496       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
3497                                AND pkg_svc.pkgpart IN ($pkgparts)
3498                                AND quantity > 0
3499                              )
3500        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
3501        LEFT JOIN cust_pkg USING ( pkgnum )
3502       ",
3503     'hashref'     => {},
3504     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
3505     'extra_param' => [ [$self->pkgnum=>'int'] ],
3506   } );
3507 }
3508
3509 =item status
3510
3511 Returns a short status string for this package, currently:
3512
3513 =over 4
3514
3515 =item on hold
3516
3517 =item not yet billed
3518
3519 =item one-time charge
3520
3521 =item active
3522
3523 =item suspended
3524
3525 =item cancelled
3526
3527 =back
3528
3529 =cut
3530
3531 sub status {
3532   my $self = shift;
3533
3534   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
3535
3536   return 'cancelled' if $self->get('cancel');
3537   return 'on hold' if $self->susp && ! $self->setup;
3538   return 'suspended' if $self->susp;
3539   return 'not yet billed' unless $self->setup;
3540   return 'one-time charge' if $freq =~ /^(0|$)/;
3541   return 'active';
3542 }
3543
3544 =item ucfirst_status
3545
3546 Returns the status with the first character capitalized.
3547
3548 =cut
3549
3550 sub ucfirst_status {
3551   ucfirst(shift->status);
3552 }
3553
3554 =item statuses
3555
3556 Class method that returns the list of possible status strings for packages
3557 (see L<the status method|/status>).  For example:
3558
3559   @statuses = FS::cust_pkg->statuses();
3560
3561 =cut
3562
3563 tie my %statuscolor, 'Tie::IxHash', 
3564   'on hold'         => 'FF00F5', #brighter purple!
3565   'not yet billed'  => '009999', #teal? cyan?
3566   'one-time charge' => '0000CC', #blue  #'000000',
3567   'active'          => '00CC00',
3568   'suspended'       => 'FF9900',
3569   'cancelled'       => 'FF0000',
3570 ;
3571
3572 sub statuses {
3573   my $self = shift; #could be class...
3574   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
3575   #                                    # mayble split btw one-time vs. recur
3576     keys %statuscolor;
3577 }
3578
3579 sub statuscolors {
3580   #my $self = shift;
3581   \%statuscolor;
3582 }
3583
3584 =item statuscolor
3585
3586 Returns a hex triplet color string for this package's status.
3587
3588 =cut
3589
3590 sub statuscolor {
3591   my $self = shift;
3592   $statuscolor{$self->status};
3593 }
3594
3595 =item is_status_delay_cancel
3596
3597 Returns true if part_pkg has option delay_cancel, 
3598 cust_pkg status is 'suspended' and expire is set
3599 to cancel package within the next day (or however
3600 many days are set in global config part_pkg-delay_cancel-days.
3601
3602 Accepts option I<part_pkg-delay_cancel-days> which should be
3603 the value of the config setting, to avoid looking it up again.
3604
3605 This is not a real status, this only meant for hacking display 
3606 values, because otherwise treating the package as suspended is 
3607 really the whole point of the delay_cancel option.
3608
3609 =cut
3610
3611 sub is_status_delay_cancel {
3612   my ($self,%opt) = @_;
3613   if ( $self->main_pkgnum and $self->pkglinknum ) {
3614     return $self->main_pkg->is_status_delay_cancel;
3615   }
3616   return 0 unless $self->part_pkg->option('delay_cancel',1);
3617   return 0 unless $self->status eq 'suspended';
3618   return 0 unless $self->expire;
3619   my $expdays = $opt{'part_pkg-delay_cancel-days'};
3620   unless ($expdays) {
3621     my $conf = new FS::Conf;
3622     $expdays = $conf->config('part_pkg-delay_cancel-days') || 1;
3623   }
3624   my $expsecs = 60*60*24*$expdays;
3625   return 0 unless $self->expire < time + $expsecs;
3626   return 1;
3627 }
3628
3629 =item pkg_label
3630
3631 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3632 "pkg - comment" depending on user preference).
3633
3634 =cut
3635
3636 sub pkg_label {
3637   my $self = shift;
3638   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
3639   $label = $self->pkgnum. ": $label"
3640     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3641   $label;
3642 }
3643
3644 =item pkg_label_long
3645
3646 Returns a long label for this package, adding the primary service's label to
3647 pkg_label.
3648
3649 =cut
3650
3651 sub pkg_label_long {
3652   my $self = shift;
3653   my $label = $self->pkg_label;
3654   my $cust_svc = $self->primary_cust_svc;
3655   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3656   $label;
3657 }
3658
3659 =item pkg_locale
3660
3661 Returns a customer-localized label for this package.
3662
3663 =cut
3664
3665 sub pkg_locale {
3666   my $self = shift;
3667   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3668 }
3669
3670 =item primary_cust_svc
3671
3672 Returns a primary service (as FS::cust_svc object) if one can be identified.
3673
3674 =cut
3675
3676 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3677
3678 sub primary_cust_svc {
3679   my $self = shift;
3680
3681   my @cust_svc = $self->cust_svc;
3682
3683   return '' unless @cust_svc; #no serivces - irrelevant then
3684   
3685   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3686
3687   # primary service as specified in the package definition
3688   # or exactly one service definition with quantity one
3689   my $svcpart = $self->part_pkg->svcpart;
3690   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3691   return $cust_svc[0] if scalar(@cust_svc) == 1;
3692
3693   #couldn't identify one thing..
3694   return '';
3695 }
3696
3697 =item labels
3698
3699 Returns a list of lists, calling the label method for all services
3700 (see L<FS::cust_svc>) of this billing item.
3701
3702 =cut
3703
3704 sub labels {
3705   my $self = shift;
3706   map { [ $_->label ] } $self->cust_svc;
3707 }
3708
3709 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3710
3711 Like the labels method, but returns historical information on services that
3712 were active as of END_TIMESTAMP and (optionally) not cancelled before
3713 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3714 I<pkg_svc.hidden> flag will be omitted.
3715
3716 Returns a list of lists, calling the label method for all (historical) services
3717 (see L<FS::h_cust_svc>) of this billing item.
3718
3719 =cut
3720
3721 sub h_labels {
3722   my $self = shift;
3723   warn "$me _h_labels called on $self\n"
3724     if $DEBUG;
3725   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3726 }
3727
3728 =item labels_short
3729
3730 Like labels, except returns a simple flat list, and shortens long
3731 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3732 identical services to one line that lists the service label and the number of
3733 individual services rather than individual items.
3734
3735 =cut
3736
3737 sub labels_short {
3738   shift->_labels_short( 'labels', @_ );
3739 }
3740
3741 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3742
3743 Like h_labels, except returns a simple flat list, and shortens long
3744 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3745 identical services to one line that lists the service label and the number of
3746 individual services rather than individual items.
3747
3748 =cut
3749
3750 sub h_labels_short {
3751   shift->_labels_short( 'h_labels', @_ );
3752 }
3753
3754 sub _labels_short {
3755   my( $self, $method ) = ( shift, shift );
3756
3757   warn "$me _labels_short called on $self with $method method\n"
3758     if $DEBUG;
3759
3760   my $conf = new FS::Conf;
3761   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3762
3763   warn "$me _labels_short populating \%labels\n"
3764     if $DEBUG;
3765
3766   my %labels;
3767   #tie %labels, 'Tie::IxHash';
3768   push @{ $labels{$_->[0]} }, $_->[1]
3769     foreach $self->$method(@_);
3770
3771   warn "$me _labels_short populating \@labels\n"
3772     if $DEBUG;
3773
3774   my @labels;
3775   foreach my $label ( keys %labels ) {
3776     my %seen = ();
3777     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3778     my $num = scalar(@values);
3779     warn "$me _labels_short $num items for $label\n"
3780       if $DEBUG;
3781
3782     if ( $num > $max_same_services ) {
3783       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3784         if $DEBUG;
3785       push @labels, "$label ($num)";
3786     } else {
3787       if ( $conf->exists('cust_bill-consolidate_services') ) {
3788         warn "$me _labels_short   consolidating services\n"
3789           if $DEBUG;
3790         # push @labels, "$label: ". join(', ', @values);
3791         while ( @values ) {
3792           my $detail = "$label: ";
3793           $detail .= shift(@values). ', '
3794             while @values
3795                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3796           $detail =~ s/, $//;
3797           push @labels, $detail;
3798         }
3799         warn "$me _labels_short   done consolidating services\n"
3800           if $DEBUG;
3801       } else {
3802         warn "$me _labels_short   adding service data\n"
3803           if $DEBUG;
3804         push @labels, map { "$label: $_" } @values;
3805       }
3806     }
3807   }
3808
3809  @labels;
3810
3811 }
3812
3813 =item cust_main
3814
3815 Returns the parent customer object (see L<FS::cust_main>).
3816
3817 =cut
3818
3819 sub cust_main {
3820   my $self = shift;
3821   cluck 'cust_pkg->cust_main called' if $DEBUG;
3822   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
3823 }
3824
3825 =item balance
3826
3827 Returns the balance for this specific package, when using
3828 experimental package balance.
3829
3830 =cut
3831
3832 sub balance {
3833   my $self = shift;
3834   $self->cust_main->balance_pkgnum( $self->pkgnum );
3835 }
3836
3837 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3838
3839 =item cust_location
3840
3841 Returns the location object, if any (see L<FS::cust_location>).
3842
3843 =item cust_location_or_main
3844
3845 If this package is associated with a location, returns the locaiton (see
3846 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3847
3848 =item location_label [ OPTION => VALUE ... ]
3849
3850 Returns the label of the location object (see L<FS::cust_location>).
3851
3852 =cut
3853
3854 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3855
3856 =item tax_locationnum
3857
3858 Returns the foreign key to a L<FS::cust_location> object for calculating  
3859 tax on this package, as determined by the C<tax-pkg_address> and 
3860 C<tax-ship_address> configuration flags.
3861
3862 =cut
3863
3864 sub tax_locationnum {
3865   my $self = shift;
3866   my $conf = FS::Conf->new;
3867   if ( $conf->exists('tax-pkg_address') ) {
3868     return $self->locationnum;
3869   }
3870   elsif ( $conf->exists('tax-ship_address') ) {
3871     return $self->cust_main->ship_locationnum;
3872   }
3873   else {
3874     return $self->cust_main->bill_locationnum;
3875   }
3876 }
3877
3878 =item tax_location
3879
3880 Returns the L<FS::cust_location> object for tax_locationnum.
3881
3882 =cut
3883
3884 sub tax_location {
3885   my $self = shift;
3886   my $conf = FS::Conf->new;
3887   if ( $conf->exists('tax-pkg_address') and $self->locationnum ) {
3888     return FS::cust_location->by_key($self->locationnum);
3889   }
3890   elsif ( $conf->exists('tax-ship_address') ) {
3891     return $self->cust_main->ship_location;
3892   }
3893   else {
3894     return $self->cust_main->bill_location;
3895   }
3896 }
3897
3898 =item seconds_since TIMESTAMP
3899
3900 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3901 package have been online since TIMESTAMP, according to the session monitor.
3902
3903 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3904 L<Time::Local> and L<Date::Parse> for conversion functions.
3905
3906 =cut
3907
3908 sub seconds_since {
3909   my($self, $since) = @_;
3910   my $seconds = 0;
3911
3912   foreach my $cust_svc (
3913     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3914   ) {
3915     $seconds += $cust_svc->seconds_since($since);
3916   }
3917
3918   $seconds;
3919
3920 }
3921
3922 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3923
3924 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3925 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3926 (exclusive).
3927
3928 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3929 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3930 functions.
3931
3932
3933 =cut
3934
3935 sub seconds_since_sqlradacct {
3936   my($self, $start, $end) = @_;
3937
3938   my $seconds = 0;
3939
3940   foreach my $cust_svc (
3941     grep {
3942       my $part_svc = $_->part_svc;
3943       $part_svc->svcdb eq 'svc_acct'
3944         && scalar($part_svc->part_export_usage);
3945     } $self->cust_svc
3946   ) {
3947     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
3948   }
3949
3950   $seconds;
3951
3952 }
3953
3954 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
3955
3956 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
3957 in this package for sessions ending between TIMESTAMP_START (inclusive) and
3958 TIMESTAMP_END
3959 (exclusive).
3960
3961 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3962 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3963 functions.
3964
3965 =cut
3966
3967 sub attribute_since_sqlradacct {
3968   my($self, $start, $end, $attrib) = @_;
3969
3970   my $sum = 0;
3971
3972   foreach my $cust_svc (
3973     grep {
3974       my $part_svc = $_->part_svc;
3975       scalar($part_svc->part_export_usage);
3976     } $self->cust_svc
3977   ) {
3978     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
3979   }
3980
3981   $sum;
3982
3983 }
3984
3985 =item quantity
3986
3987 =cut
3988
3989 sub quantity {
3990   my( $self, $value ) = @_;
3991   if ( defined($value) ) {
3992     $self->setfield('quantity', $value);
3993   }
3994   $self->getfield('quantity') || 1;
3995 }
3996
3997 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
3998
3999 Transfers as many services as possible from this package to another package.
4000
4001 The destination package can be specified by pkgnum by passing an FS::cust_pkg
4002 object.  The destination package must already exist.
4003
4004 Services are moved only if the destination allows services with the correct
4005 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
4006 this option with caution!  No provision is made for export differences
4007 between the old and new service definitions.  Probably only should be used
4008 when your exports for all service definitions of a given svcdb are identical.
4009 (attempt a transfer without it first, to move all possible svcpart-matching
4010 services)
4011
4012 Any services that can't be moved remain in the original package.
4013
4014 Returns an error, if there is one; otherwise, returns the number of services 
4015 that couldn't be moved.
4016
4017 =cut
4018
4019 sub transfer {
4020   my ($self, $dest_pkgnum, %opt) = @_;
4021
4022   my $remaining = 0;
4023   my $dest;
4024   my %target;
4025
4026   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
4027     $dest = $dest_pkgnum;
4028     $dest_pkgnum = $dest->pkgnum;
4029   } else {
4030     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
4031   }
4032
4033   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
4034
4035   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
4036     $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 );
4037   }
4038
4039   foreach my $cust_svc ($dest->cust_svc) {
4040     $target{$cust_svc->svcpart}--;
4041   }
4042
4043   my %svcpart2svcparts = ();
4044   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4045     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
4046     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
4047       next if exists $svcpart2svcparts{$svcpart};
4048       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
4049       $svcpart2svcparts{$svcpart} = [
4050         map  { $_->[0] }
4051         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
4052         map {
4053               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
4054                                                    'svcpart' => $_          } );
4055               [ $_,
4056                 $pkg_svc ? $pkg_svc->primary_svc : '',
4057                 $pkg_svc ? $pkg_svc->quantity : 0,
4058               ];
4059             }
4060
4061         grep { $_ != $svcpart }
4062         map  { $_->svcpart }
4063         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
4064       ];
4065       warn "alternates for svcpart $svcpart: ".
4066            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
4067         if $DEBUG;
4068     }
4069   }
4070
4071   my $error;
4072   foreach my $cust_svc ($self->cust_svc) {
4073     my $svcnum = $cust_svc->svcnum;
4074     if($target{$cust_svc->svcpart} > 0
4075        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
4076       $target{$cust_svc->svcpart}--;
4077       my $new = new FS::cust_svc { $cust_svc->hash };
4078       $new->pkgnum($dest_pkgnum);
4079       $error = $new->replace($cust_svc);
4080     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4081       if ( $DEBUG ) {
4082         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
4083         warn "alternates to consider: ".
4084              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
4085       }
4086       my @alternate = grep {
4087                              warn "considering alternate svcpart $_: ".
4088                                   "$target{$_} available in new package\n"
4089                                if $DEBUG;
4090                              $target{$_} > 0;
4091                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
4092       if ( @alternate ) {
4093         warn "alternate(s) found\n" if $DEBUG;
4094         my $change_svcpart = $alternate[0];
4095         $target{$change_svcpart}--;
4096         my $new = new FS::cust_svc { $cust_svc->hash };
4097         $new->svcpart($change_svcpart);
4098         $new->pkgnum($dest_pkgnum);
4099         $error = $new->replace($cust_svc);
4100       } else {
4101         $remaining++;
4102       }
4103     } else {
4104       $remaining++
4105     }
4106     if ( $error ) {
4107       my @label = $cust_svc->label;
4108       return "service $label[1]: $error";
4109     }
4110   }
4111   return $remaining;
4112 }
4113
4114 =item grab_svcnums SVCNUM, SVCNUM ...
4115
4116 Change the pkgnum for the provided services to this packages.  If there is an
4117 error, returns the error, otherwise returns false.
4118
4119 =cut
4120
4121 sub grab_svcnums {
4122   my $self = shift;
4123   my @svcnum = @_;
4124
4125   local $SIG{HUP} = 'IGNORE';
4126   local $SIG{INT} = 'IGNORE';
4127   local $SIG{QUIT} = 'IGNORE';
4128   local $SIG{TERM} = 'IGNORE';
4129   local $SIG{TSTP} = 'IGNORE';
4130   local $SIG{PIPE} = 'IGNORE';
4131
4132   my $oldAutoCommit = $FS::UID::AutoCommit;
4133   local $FS::UID::AutoCommit = 0;
4134   my $dbh = dbh;
4135
4136   foreach my $svcnum (@svcnum) {
4137     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
4138       $dbh->rollback if $oldAutoCommit;
4139       return "unknown svcnum $svcnum";
4140     };
4141     $cust_svc->pkgnum( $self->pkgnum );
4142     my $error = $cust_svc->replace;
4143     if ( $error ) {
4144       $dbh->rollback if $oldAutoCommit;
4145       return $error;
4146     }
4147   }
4148
4149   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4150   '';
4151
4152 }
4153
4154 =item reexport
4155
4156 This method is deprecated.  See the I<depend_jobnum> option to the insert and
4157 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
4158
4159 =cut
4160
4161 sub reexport {
4162   my $self = shift;
4163
4164   local $SIG{HUP} = 'IGNORE';
4165   local $SIG{INT} = 'IGNORE';
4166   local $SIG{QUIT} = 'IGNORE';
4167   local $SIG{TERM} = 'IGNORE';
4168   local $SIG{TSTP} = 'IGNORE';
4169   local $SIG{PIPE} = 'IGNORE';
4170
4171   my $oldAutoCommit = $FS::UID::AutoCommit;
4172   local $FS::UID::AutoCommit = 0;
4173   my $dbh = dbh;
4174
4175   foreach my $cust_svc ( $self->cust_svc ) {
4176     #false laziness w/svc_Common::insert
4177     my $svc_x = $cust_svc->svc_x;
4178     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
4179       my $error = $part_export->export_insert($svc_x);
4180       if ( $error ) {
4181         $dbh->rollback if $oldAutoCommit;
4182         return $error;
4183       }
4184     }
4185   }
4186
4187   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4188   '';
4189
4190 }
4191
4192 =item export_pkg_change OLD_CUST_PKG
4193
4194 Calls the "pkg_change" export action for all services attached to this package.
4195
4196 =cut
4197
4198 sub export_pkg_change {
4199   my( $self, $old )  = ( shift, shift );
4200
4201   local $SIG{HUP} = 'IGNORE';
4202   local $SIG{INT} = 'IGNORE';
4203   local $SIG{QUIT} = 'IGNORE';
4204   local $SIG{TERM} = 'IGNORE';
4205   local $SIG{TSTP} = 'IGNORE';
4206   local $SIG{PIPE} = 'IGNORE';
4207
4208   my $oldAutoCommit = $FS::UID::AutoCommit;
4209   local $FS::UID::AutoCommit = 0;
4210   my $dbh = dbh;
4211
4212   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
4213     my $error = $svc_x->export('pkg_change', $self, $old);
4214     if ( $error ) {
4215       $dbh->rollback if $oldAutoCommit;
4216       return $error;
4217     }
4218   }
4219
4220   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4221   '';
4222
4223 }
4224
4225 =item insert_reason
4226
4227 Associates this package with a (suspension or cancellation) reason (see
4228 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
4229 L<FS::reason>).
4230
4231 Available options are:
4232
4233 =over 4
4234
4235 =item reason
4236
4237 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.
4238
4239 =item reason_otaker
4240
4241 the access_user (see L<FS::access_user>) providing the reason
4242
4243 =item date
4244
4245 a unix timestamp 
4246
4247 =item action
4248
4249 the action (cancel, susp, adjourn, expire) associated with the reason
4250
4251 =back
4252
4253 If there is an error, returns the error, otherwise returns false.
4254
4255 =cut
4256
4257 sub insert_reason {
4258   my ($self, %options) = @_;
4259
4260   my $otaker = $options{reason_otaker} ||
4261                $FS::CurrentUser::CurrentUser->username;
4262
4263   my $reasonnum;
4264   if ( $options{'reason'} =~ /^(\d+)$/ ) {
4265
4266     $reasonnum = $1;
4267
4268   } elsif ( ref($options{'reason'}) ) {
4269   
4270     return 'Enter a new reason (or select an existing one)'
4271       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
4272
4273     my $reason = new FS::reason({
4274       'reason_type' => $options{'reason'}->{'typenum'},
4275       'reason'      => $options{'reason'}->{'reason'},
4276     });
4277     my $error = $reason->insert;
4278     return $error if $error;
4279
4280     $reasonnum = $reason->reasonnum;
4281
4282   } else {
4283     return "Unparseable reason: ". $options{'reason'};
4284   }
4285
4286   my $cust_pkg_reason =
4287     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
4288                               'reasonnum' => $reasonnum, 
4289                               'otaker'    => $otaker,
4290                               'action'    => substr(uc($options{'action'}),0,1),
4291                               'date'      => $options{'date'}
4292                                                ? $options{'date'}
4293                                                : time,
4294                             });
4295
4296   $cust_pkg_reason->insert;
4297 }
4298
4299 =item insert_discount
4300
4301 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
4302 inserting a new discount on the fly (see L<FS::discount>).
4303
4304 Available options are:
4305
4306 =over 4
4307
4308 =item discountnum
4309
4310 =back
4311
4312 If there is an error, returns the error, otherwise returns false.
4313
4314 =cut
4315
4316 sub insert_discount {
4317   #my ($self, %options) = @_;
4318   my $self = shift;
4319
4320   my $cust_pkg_discount = new FS::cust_pkg_discount {
4321     'pkgnum'      => $self->pkgnum,
4322     'discountnum' => $self->discountnum,
4323     'months_used' => 0,
4324     'end_date'    => '', #XXX
4325     #for the create a new discount case
4326     '_type'       => $self->discountnum__type,
4327     'amount'      => $self->discountnum_amount,
4328     'percent'     => $self->discountnum_percent,
4329     'months'      => $self->discountnum_months,
4330     'setup'      => $self->discountnum_setup,
4331     #'disabled'    => $self->discountnum_disabled,
4332   };
4333
4334   $cust_pkg_discount->insert;
4335 }
4336
4337 =item set_usage USAGE_VALUE_HASHREF 
4338
4339 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4340 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4341 upbytes, downbytes, and totalbytes are appropriate keys.
4342
4343 All svc_accts which are part of this package have their values reset.
4344
4345 =cut
4346
4347 sub set_usage {
4348   my ($self, $valueref, %opt) = @_;
4349
4350   #only svc_acct can set_usage for now
4351   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4352     my $svc_x = $cust_svc->svc_x;
4353     $svc_x->set_usage($valueref, %opt)
4354       if $svc_x->can("set_usage");
4355   }
4356 }
4357
4358 =item recharge USAGE_VALUE_HASHREF 
4359
4360 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4361 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4362 upbytes, downbytes, and totalbytes are appropriate keys.
4363
4364 All svc_accts which are part of this package have their values incremented.
4365
4366 =cut
4367
4368 sub recharge {
4369   my ($self, $valueref) = @_;
4370
4371   #only svc_acct can set_usage for now
4372   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4373     my $svc_x = $cust_svc->svc_x;
4374     $svc_x->recharge($valueref)
4375       if $svc_x->can("recharge");
4376   }
4377 }
4378
4379 =item cust_pkg_discount
4380
4381 =cut
4382
4383 sub cust_pkg_discount {
4384   my $self = shift;
4385   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
4386 }
4387
4388 =item cust_pkg_discount_active
4389
4390 =cut
4391
4392 sub cust_pkg_discount_active {
4393   my $self = shift;
4394   grep { $_->status eq 'active' } $self->cust_pkg_discount;
4395 }
4396
4397 =item cust_pkg_usage
4398
4399 Returns a list of all voice usage counters attached to this package.
4400
4401 =cut
4402
4403 sub cust_pkg_usage {
4404   my $self = shift;
4405   qsearch('cust_pkg_usage', { pkgnum => $self->pkgnum });
4406 }
4407
4408 =item apply_usage OPTIONS
4409
4410 Takes the following options:
4411 - cdr: a call detail record (L<FS::cdr>)
4412 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
4413 - minutes: the maximum number of minutes to be charged
4414
4415 Finds available usage minutes for a call of this class, and subtracts
4416 up to that many minutes from the usage pool.  If the usage pool is empty,
4417 and the C<cdr-minutes_priority> global config option is set, minutes may
4418 be taken from other calls as well.  Either way, an allocation record will
4419 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
4420 number of minutes of usage applied to the call.
4421
4422 =cut
4423
4424 sub apply_usage {
4425   my ($self, %opt) = @_;
4426   my $cdr = $opt{cdr};
4427   my $rate_detail = $opt{rate_detail};
4428   my $minutes = $opt{minutes};
4429   my $classnum = $rate_detail->classnum;
4430   my $pkgnum = $self->pkgnum;
4431   my $custnum = $self->custnum;
4432
4433   local $SIG{HUP} = 'IGNORE';
4434   local $SIG{INT} = 'IGNORE'; 
4435   local $SIG{QUIT} = 'IGNORE';
4436   local $SIG{TERM} = 'IGNORE';
4437   local $SIG{TSTP} = 'IGNORE'; 
4438   local $SIG{PIPE} = 'IGNORE'; 
4439
4440   my $oldAutoCommit = $FS::UID::AutoCommit;
4441   local $FS::UID::AutoCommit = 0;
4442   my $dbh = dbh;
4443   my $order = FS::Conf->new->config('cdr-minutes_priority');
4444
4445   my $is_classnum;
4446   if ( $classnum ) {
4447     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
4448   } else {
4449     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
4450   }
4451   my @usage_recs = qsearch({
4452       'table'     => 'cust_pkg_usage',
4453       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
4454                      ' JOIN cust_pkg             USING (pkgnum)'.
4455                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
4456       'select'    => 'cust_pkg_usage.*',
4457       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
4458                      " ( cust_pkg.custnum = $custnum AND ".
4459                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4460                      $is_classnum . ' AND '.
4461                      " cust_pkg_usage.minutes > 0",
4462       'order_by'  => " ORDER BY priority ASC",
4463   });
4464
4465   my $orig_minutes = $minutes;
4466   my $error;
4467   while (!$error and $minutes > 0 and @usage_recs) {
4468     my $cust_pkg_usage = shift @usage_recs;
4469     $cust_pkg_usage->select_for_update;
4470     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
4471         pkgusagenum => $cust_pkg_usage->pkgusagenum,
4472         acctid      => $cdr->acctid,
4473         minutes     => min($cust_pkg_usage->minutes, $minutes),
4474     });
4475     $cust_pkg_usage->set('minutes',
4476       $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes
4477     );
4478     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
4479     $minutes -= $cdr_cust_pkg_usage->minutes;
4480   }
4481   if ( $order and $minutes > 0 and !$error ) {
4482     # then try to steal minutes from another call
4483     my %search = (
4484         'table'     => 'cdr_cust_pkg_usage',
4485         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
4486                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
4487                        ' JOIN cust_pkg              USING (pkgnum)'.
4488                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
4489                        ' JOIN cdr                   USING (acctid)',
4490         'select'    => 'cdr_cust_pkg_usage.*',
4491         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
4492                        " ( cust_pkg.pkgnum = $pkgnum OR ".
4493                        " ( cust_pkg.custnum = $custnum AND ".
4494                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4495                        " part_pkg_usage_class.classnum = $classnum",
4496         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
4497     );
4498     if ( $order eq 'time' ) {
4499       # find CDRs that are using minutes, but have a later startdate
4500       # than this call
4501       my $startdate = $cdr->startdate;
4502       if ($startdate !~ /^\d+$/) {
4503         die "bad cdr startdate '$startdate'";
4504       }
4505       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
4506       # minimize needless reshuffling
4507       $search{'order_by'} .= ', cdr.startdate DESC';
4508     } else {
4509       # XXX may not work correctly with rate_time schedules.  Could 
4510       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
4511       # think...
4512       $search{'addl_from'} .=
4513         ' JOIN rate_detail'.
4514         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4515       if ( $order eq 'rate_high' ) {
4516         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4517                                 $rate_detail->min_charge;
4518         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4519       } elsif ( $order eq 'rate_low' ) {
4520         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4521                                 $rate_detail->min_charge;
4522         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4523       } else {
4524         #  this should really never happen
4525         die "invalid cdr-minutes_priority value '$order'\n";
4526       }
4527     }
4528     my @cdr_usage_recs = qsearch(\%search);
4529     my %reproc_cdrs;
4530     while (!$error and @cdr_usage_recs and $minutes > 0) {
4531       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4532       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4533       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4534       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4535       $cdr_cust_pkg_usage->select_for_update;
4536       $old_cdr->select_for_update;
4537       $cust_pkg_usage->select_for_update;
4538       # in case someone else stole the usage from this CDR
4539       # while waiting for the lock...
4540       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4541       # steal the usage allocation and flag the old CDR for reprocessing
4542       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4543       # if the allocation is more minutes than we need, adjust it...
4544       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4545       if ( $delta > 0 ) {
4546         $cdr_cust_pkg_usage->set('minutes', $minutes);
4547         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4548         $error = $cust_pkg_usage->replace;
4549       }
4550       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4551       $error ||= $cdr_cust_pkg_usage->replace;
4552       # deduct the stolen minutes
4553       $minutes -= $cdr_cust_pkg_usage->minutes;
4554     }
4555     # after all minute-stealing is done, reset the affected CDRs
4556     foreach (values %reproc_cdrs) {
4557       $error ||= $_->set_status('');
4558       # XXX or should we just call $cdr->rate right here?
4559       # it's not like we can create a loop this way, since the min_charge
4560       # or call time has to go monotonically in one direction.
4561       # we COULD get some very deep recursions going, though...
4562     }
4563   } # if $order and $minutes
4564   if ( $error ) {
4565     $dbh->rollback;
4566     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4567   } else {
4568     $dbh->commit if $oldAutoCommit;
4569     return $orig_minutes - $minutes;
4570   }
4571 }
4572
4573 =item supplemental_pkgs
4574
4575 Returns a list of all packages supplemental to this one.
4576
4577 =cut
4578
4579 sub supplemental_pkgs {
4580   my $self = shift;
4581   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4582 }
4583
4584 =item main_pkg
4585
4586 Returns the package that this one is supplemental to, if any.
4587
4588 =cut
4589
4590 sub main_pkg {
4591   my $self = shift;
4592   if ( $self->main_pkgnum ) {
4593     return FS::cust_pkg->by_key($self->main_pkgnum);
4594   }
4595   return;
4596 }
4597
4598 =back
4599
4600 =head1 CLASS METHODS
4601
4602 =over 4
4603
4604 =item recurring_sql
4605
4606 Returns an SQL expression identifying recurring packages.
4607
4608 =cut
4609
4610 sub recurring_sql { "
4611   '0' != ( select freq from part_pkg
4612              where cust_pkg.pkgpart = part_pkg.pkgpart )
4613 "; }
4614
4615 =item onetime_sql
4616
4617 Returns an SQL expression identifying one-time packages.
4618
4619 =cut
4620
4621 sub onetime_sql { "
4622   '0' = ( select freq from part_pkg
4623             where cust_pkg.pkgpart = part_pkg.pkgpart )
4624 "; }
4625
4626 =item ordered_sql
4627
4628 Returns an SQL expression identifying ordered packages (recurring packages not
4629 yet billed).
4630
4631 =cut
4632
4633 sub ordered_sql {
4634    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4635 }
4636
4637 =item active_sql
4638
4639 Returns an SQL expression identifying active packages.
4640
4641 =cut
4642
4643 sub active_sql {
4644   $_[0]->recurring_sql. "
4645   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4646   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4647   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4648 "; }
4649
4650 =item not_yet_billed_sql
4651
4652 Returns an SQL expression identifying packages which have not yet been billed.
4653
4654 =cut
4655
4656 sub not_yet_billed_sql { "
4657       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4658   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4659   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4660 "; }
4661
4662 =item inactive_sql
4663
4664 Returns an SQL expression identifying inactive packages (one-time packages
4665 that are otherwise unsuspended/uncancelled).
4666
4667 =cut
4668
4669 sub inactive_sql { "
4670   ". $_[0]->onetime_sql(). "
4671   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4672   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4673   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4674 "; }
4675
4676 =item on_hold_sql
4677
4678 Returns an SQL expression identifying on-hold packages.
4679
4680 =cut
4681
4682 sub on_hold_sql {
4683   #$_[0]->recurring_sql(). ' AND '.
4684   "
4685         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel  = 0 )
4686     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp   != 0
4687     AND ( cust_pkg.setup  IS     NULL  OR cust_pkg.setup   = 0 )
4688   ";
4689 }
4690
4691 =item susp_sql
4692 =item suspended_sql
4693
4694 Returns an SQL expression identifying suspended packages.
4695
4696 =cut
4697
4698 sub suspended_sql { susp_sql(@_); }
4699 sub susp_sql {
4700   #$_[0]->recurring_sql(). ' AND '.
4701   "
4702         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4703     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4704     AND   cust_pkg.setup  IS NOT NULL AND cust_pkg.setup != 0
4705   ";
4706 }
4707
4708 =item cancel_sql
4709 =item cancelled_sql
4710
4711 Returns an SQL exprression identifying cancelled packages.
4712
4713 =cut
4714
4715 sub cancelled_sql { cancel_sql(@_); }
4716 sub cancel_sql { 
4717   #$_[0]->recurring_sql(). ' AND '.
4718   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4719 }
4720
4721 =item status_sql
4722
4723 Returns an SQL expression to give the package status as a string.
4724
4725 =cut
4726
4727 sub status_sql {
4728 "CASE
4729   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4730   WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold'
4731   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4732   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4733   WHEN ".onetime_sql()." THEN 'one-time charge'
4734   ELSE 'active'
4735 END"
4736 }
4737
4738 =item search HASHREF
4739
4740 (Class method)
4741
4742 Returns a qsearch hash expression to search for parameters specified in HASHREF.
4743 Valid parameters are
4744
4745 =over 4
4746
4747 =item agentnum
4748
4749 =item status
4750
4751 on hold, active, inactive (or one-time charge), suspended, canceled (or cancelled)
4752
4753 =item magic
4754
4755 Equivalent to "status", except that "canceled"/"cancelled" will exclude 
4756 packages that were changed into a new package with the same pkgpart (i.e.
4757 location or quantity changes).
4758
4759 =item custom
4760
4761  boolean selects custom packages
4762
4763 =item classnum
4764
4765 =item pkgpart
4766
4767 pkgpart or arrayref or hashref of pkgparts
4768
4769 =item setup
4770
4771 arrayref of beginning and ending epoch date
4772
4773 =item last_bill
4774
4775 arrayref of beginning and ending epoch date
4776
4777 =item bill
4778
4779 arrayref of beginning and ending epoch date
4780
4781 =item adjourn
4782
4783 arrayref of beginning and ending epoch date
4784
4785 =item susp
4786
4787 arrayref of beginning and ending epoch date
4788
4789 =item expire
4790
4791 arrayref of beginning and ending epoch date
4792
4793 =item cancel
4794
4795 arrayref of beginning and ending epoch date
4796
4797 =item query
4798
4799 pkgnum or APKG_pkgnum
4800
4801 =item cust_fields
4802
4803 a value suited to passing to FS::UI::Web::cust_header
4804
4805 =item CurrentUser
4806
4807 specifies the user for agent virtualization
4808
4809 =item fcc_line
4810
4811 boolean; if true, returns only packages with more than 0 FCC phone lines.
4812
4813 =item state, country
4814
4815 Limit to packages with a service location in the specified state and country.
4816 For FCC 477 reporting, mostly.
4817
4818 =item location_cust
4819
4820 Limit to packages whose service locations are the same as the customer's 
4821 default service location.
4822
4823 =item location_nocust
4824
4825 Limit to packages whose service locations are not the customer's default 
4826 service location.
4827
4828 =item location_census
4829
4830 Limit to packages whose service locations have census tracts.
4831
4832 =item location_nocensus
4833
4834 Limit to packages whose service locations do not have a census tract.
4835
4836 =item location_geocode
4837
4838 Limit to packages whose locations have geocodes.
4839
4840 =item location_geocode
4841
4842 Limit to packages whose locations do not have geocodes.
4843
4844 =item towernum
4845
4846 Limit to packages associated with a svc_broadband, associated with a sector,
4847 associated with this towernum (or any of these, if it's an arrayref) (or NO
4848 towernum, if it's zero). This is an extreme niche case.
4849
4850 =item 477part, 477rownum, date
4851
4852 Limit to packages included in a specific row of one of the FCC 477 reports.
4853 '477part' is the section name (see L<FS::Report::FCC_477> methods), 'date'
4854 is the report as-of date (completely unrelated to the package setup/bill/
4855 other date fields), and '477rownum' is the row number of the report starting
4856 with zero. Row numbers have no inherent meaning, so this is useful only 
4857 for explaining a 477 report you've already run.
4858
4859 =back
4860
4861 =cut
4862
4863 sub search {
4864   my ($class, $params) = @_;
4865   my @where = ();
4866
4867   ##
4868   # parse agent
4869   ##
4870
4871   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
4872     push @where,
4873       "cust_main.agentnum = $1";
4874   }
4875
4876   ##
4877   # parse cust_status
4878   ##
4879
4880   if ( $params->{'cust_status'} =~ /^([a-z]+)$/ ) {
4881     push @where, FS::cust_main->cust_status_sql . " = '$1' ";
4882   }
4883
4884   ##
4885   # parse customer sales person
4886   ##
4887
4888   if ( $params->{'cust_main_salesnum'} =~ /^(\d+)$/ ) {
4889     push @where, ($1 > 0) ? "cust_main.salesnum = $1"
4890                           : 'cust_main.salesnum IS NULL';
4891   }
4892
4893
4894   ##
4895   # parse sales person
4896   ##
4897
4898   if ( $params->{'salesnum'} =~ /^(\d+)$/ ) {
4899     push @where, ($1 > 0) ? "cust_pkg.salesnum = $1"
4900                           : 'cust_pkg.salesnum IS NULL';
4901   }
4902
4903   ##
4904   # parse custnum
4905   ##
4906
4907   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
4908     push @where,
4909       "cust_pkg.custnum = $1";
4910   }
4911
4912   ##
4913   # custbatch
4914   ##
4915
4916   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
4917     push @where,
4918       "cust_pkg.pkgbatch = '$1'";
4919   }
4920
4921   ##
4922   # parse status
4923   ##
4924
4925   if (    $params->{'magic'}  eq 'active'
4926        || $params->{'status'} eq 'active' ) {
4927
4928     push @where, FS::cust_pkg->active_sql();
4929
4930   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
4931             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
4932
4933     push @where, FS::cust_pkg->not_yet_billed_sql();
4934
4935   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
4936             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
4937
4938     push @where, FS::cust_pkg->inactive_sql();
4939
4940   } elsif (    $params->{'magic'}  =~ /^on[ _]hold$/
4941             || $params->{'status'} =~ /^on[ _]hold$/ ) {
4942
4943     push @where, FS::cust_pkg->on_hold_sql();
4944
4945
4946   } elsif (    $params->{'magic'}  eq 'suspended'
4947             || $params->{'status'} eq 'suspended'  ) {
4948
4949     push @where, FS::cust_pkg->suspended_sql();
4950
4951   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
4952             || $params->{'status'} =~ /^cancell?ed$/ ) {
4953
4954     push @where, FS::cust_pkg->cancelled_sql();
4955
4956   }
4957   
4958   ### special case: "magic" is used in detail links from browse/part_pkg,
4959   # where "cancelled" has the restriction "and not replaced with a package
4960   # of the same pkgpart".  Be consistent with that.
4961   ###
4962
4963   if ( $params->{'magic'} =~ /^cancell?ed$/ ) {
4964     my $new_pkgpart = "SELECT pkgpart FROM cust_pkg AS cust_pkg_next ".
4965                       "WHERE cust_pkg_next.change_pkgnum = cust_pkg.pkgnum";
4966     # ...may not exist, if this was just canceled and not changed; in that
4967     # case give it a "new pkgpart" that never equals the old pkgpart
4968     push @where, "COALESCE(($new_pkgpart), 0) != cust_pkg.pkgpart";
4969   }
4970
4971   ###
4972   # parse package class
4973   ###
4974
4975   if ( exists($params->{'classnum'}) ) {
4976
4977     my @classnum = ();
4978     if ( ref($params->{'classnum'}) ) {
4979
4980       if ( ref($params->{'classnum'}) eq 'HASH' ) {
4981         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
4982       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
4983         @classnum = @{ $params->{'classnum'} };
4984       } else {
4985         die 'unhandled classnum ref '. $params->{'classnum'};
4986       }
4987
4988
4989     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
4990       @classnum = ( $1 );
4991     }
4992
4993     if ( @classnum ) {
4994
4995       my @c_where = ();
4996       my @nums = grep $_, @classnum;
4997       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
4998       my $null = scalar( grep { $_ eq '' } @classnum );
4999       push @c_where, 'part_pkg.classnum IS NULL' if $null;
5000
5001       if ( scalar(@c_where) == 1 ) {
5002         push @where, @c_where;
5003       } elsif ( @c_where ) {
5004         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
5005       }
5006
5007     }
5008     
5009
5010   }
5011
5012   ###
5013   # parse refnum (advertising source)
5014   ###
5015
5016   if ( exists($params->{'refnum'}) ) {
5017     my @refnum;
5018     if (ref $params->{'refnum'}) {
5019       @refnum = @{ $params->{'refnum'} };
5020     } else {
5021       @refnum = ( $params->{'refnum'} );
5022     }
5023     my $in = join(',', grep /^\d+$/, @refnum);
5024     push @where, "refnum IN($in)" if length $in;
5025   }
5026
5027   ###
5028   # parse package report options
5029   ###
5030
5031   my @report_option = ();
5032   if ( exists($params->{'report_option'}) ) {
5033     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
5034       @report_option = @{ $params->{'report_option'} };
5035     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
5036       @report_option = split(',', $1);
5037     }
5038
5039   }
5040
5041   if (@report_option) {
5042     # this will result in the empty set for the dangling comma case as it should
5043     push @where, 
5044       map{ "0 < ( SELECT count(*) FROM part_pkg_option
5045                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
5046                     AND optionname = 'report_option_$_'
5047                     AND optionvalue = '1' )"
5048          } @report_option;
5049   }
5050
5051   foreach my $any ( grep /^report_option_any/, keys %$params ) {
5052
5053     my @report_option_any = ();
5054     if ( ref($params->{$any}) eq 'ARRAY' ) {
5055       @report_option_any = @{ $params->{$any} };
5056     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
5057       @report_option_any = split(',', $1);
5058     }
5059
5060     if (@report_option_any) {
5061       # this will result in the empty set for the dangling comma case as it should
5062       push @where, ' ( '. join(' OR ',
5063         map{ "0 < ( SELECT count(*) FROM part_pkg_option
5064                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
5065                       AND optionname = 'report_option_$_'
5066                       AND optionvalue = '1' )"
5067            } @report_option_any
5068       ). ' ) ';
5069     }
5070
5071   }
5072
5073   ###
5074   # parse custom
5075   ###
5076
5077   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
5078
5079   ###
5080   # parse fcc_line
5081   ###
5082
5083   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
5084                                                         if $params->{fcc_line};
5085
5086   ###
5087   # parse censustract
5088   ###
5089
5090   if ( exists($params->{'censustract'}) ) {
5091     $params->{'censustract'} =~ /^([.\d]*)$/;
5092     my $censustract = "cust_location.censustract = '$1'";
5093     $censustract .= ' OR cust_location.censustract is NULL' unless $1;
5094     push @where,  "( $censustract )";
5095   }
5096
5097   ###
5098   # parse censustract2
5099   ###
5100   if ( exists($params->{'censustract2'})
5101        && $params->{'censustract2'} =~ /^(\d*)$/
5102      )
5103   {
5104     if ($1) {
5105       push @where, "cust_location.censustract LIKE '$1%'";
5106     } else {
5107       push @where,
5108         "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
5109     }
5110   }
5111
5112   ###
5113   # parse country/state/zip
5114   ###
5115   for (qw(state country)) { # parsing rules are the same for these
5116   if ( exists($params->{$_}) 
5117     && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
5118     {
5119       # XXX post-2.3 only--before that, state/country may be in cust_main
5120       push @where, "cust_location.$_ = '$1'";
5121     }
5122   }
5123   if ( exists($params->{zip}) ) {
5124     push @where, "cust_location.zip = " . dbh->quote($params->{zip});
5125   }
5126
5127   ###
5128   # location_* flags
5129   ###
5130   if ( $params->{location_cust} xor $params->{location_nocust} ) {
5131     my $op = $params->{location_cust} ? '=' : '!=';
5132     push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
5133   }
5134   if ( $params->{location_census} xor $params->{location_nocensus} ) {
5135     my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
5136     push @where, "cust_location.censustract $op";
5137   }
5138   if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
5139     my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
5140     push @where, "cust_location.geocode $op";
5141   }
5142
5143   ###
5144   # parse part_pkg
5145   ###
5146
5147   if ( ref($params->{'pkgpart'}) ) {
5148
5149     my @pkgpart = ();
5150     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
5151       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
5152     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
5153       @pkgpart = @{ $params->{'pkgpart'} };
5154     } else {
5155       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
5156     }
5157
5158     @pkgpart = grep /^(\d+)$/, @pkgpart;
5159
5160     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
5161
5162   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
5163     push @where, "pkgpart = $1";
5164   } 
5165
5166   ###
5167   # parse dates
5168   ###
5169
5170   my $orderby = '';
5171
5172   #false laziness w/report_cust_pkg.html
5173   my %disable = (
5174     'all'             => {},
5175     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
5176     'active'          => { 'susp'=>1, 'cancel'=>1 },
5177     'suspended'       => { 'cancel' => 1 },
5178     'cancelled'       => {},
5179     ''                => {},
5180   );
5181
5182   if( exists($params->{'active'} ) ) {
5183     # This overrides all the other date-related fields, and includes packages
5184     # that were active at some time during the interval.  It excludes:
5185     # - packages that were set up after the end of the interval
5186     # - packages that were canceled before the start of the interval
5187     # - packages that were suspended before the start of the interval
5188     #   and are still suspended now
5189     my($beginning, $ending) = @{$params->{'active'}};
5190     push @where,
5191       "cust_pkg.setup IS NOT NULL",
5192       "cust_pkg.setup <= $ending",
5193       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
5194       "(cust_pkg.susp   IS NULL OR cust_pkg.susp   >= $beginning )",
5195       "NOT (".FS::cust_pkg->onetime_sql . ")";
5196   }
5197   else {
5198     my $exclude_change_from = 0;
5199     my $exclude_change_to = 0;
5200
5201     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
5202
5203       next unless exists($params->{$field});
5204
5205       my($beginning, $ending) = @{$params->{$field}};
5206
5207       next if $beginning == 0 && $ending == 4294967295;
5208
5209       push @where,
5210         "cust_pkg.$field IS NOT NULL",
5211         "cust_pkg.$field >= $beginning",
5212         "cust_pkg.$field <= $ending";
5213
5214       $orderby ||= "ORDER BY cust_pkg.$field";
5215
5216       if ( $field eq 'setup' ) {
5217         $exclude_change_from = 1;
5218       } elsif ( $field eq 'cancel' ) {
5219         $exclude_change_to = 1;
5220       } elsif ( $field eq 'change_date' ) {
5221         # if we are given setup and change_date ranges, and the setup date
5222         # falls in _both_ ranges, then include the package whether it was 
5223         # a change or not
5224         $exclude_change_from = 0;
5225       }
5226     }
5227
5228     if ($exclude_change_from) {
5229       push @where, "change_pkgnum IS NULL";
5230     }
5231     if ($exclude_change_to) {
5232       # a join might be more efficient here
5233       push @where, "NOT EXISTS(
5234         SELECT 1 FROM cust_pkg AS changed_to_pkg
5235         WHERE cust_pkg.pkgnum = changed_to_pkg.change_pkgnum
5236       )";
5237     }
5238   }
5239
5240   $orderby ||= 'ORDER BY bill';
5241
5242   ###
5243   # parse magic, legacy, etc.
5244   ###
5245
5246   if ( $params->{'magic'} &&
5247        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
5248   ) {
5249
5250     $orderby = 'ORDER BY pkgnum';
5251
5252     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
5253       push @where, "pkgpart = $1";
5254     }
5255
5256   } elsif ( $params->{'query'} eq 'pkgnum' ) {
5257
5258     $orderby = 'ORDER BY pkgnum';
5259
5260   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
5261
5262     $orderby = 'ORDER BY pkgnum';
5263
5264     push @where, '0 < (
5265       SELECT count(*) FROM pkg_svc
5266        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
5267          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
5268                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
5269                                      AND cust_svc.svcpart = pkg_svc.svcpart
5270                                 )
5271     )';
5272   
5273   }
5274
5275   ##
5276   # parse the extremely weird 'towernum' param
5277   ##
5278
5279   if ($params->{towernum}) {
5280     my $towernum = $params->{towernum};
5281     $towernum = [ $towernum ] if !ref($towernum);
5282     my $in = join(',', grep /^\d+$/, @$towernum);
5283     if (length $in) {
5284       # inefficient, but this is an obscure feature
5285       eval "use FS::Report::Table";
5286       FS::Report::Table->_init_tower_pkg_cache; # probably does nothing
5287       push @where, "EXISTS(
5288       SELECT 1 FROM tower_pkg_cache
5289       WHERE tower_pkg_cache.pkgnum = cust_pkg.pkgnum
5290         AND tower_pkg_cache.towernum IN ($in)
5291       )"
5292     }
5293   }
5294
5295   ##
5296   # parse the 477 report drill-down options
5297   ##
5298
5299   if ($params->{'477part'} =~ /^([a-z]+)$/) {
5300     my $section = $1;
5301     my ($date, $rownum, $agentnum);
5302     if ($params->{'date'} =~ /^(\d+)$/) {
5303       $date = $1;
5304     }
5305     if ($params->{'477rownum'} =~ /^(\d+)$/) {
5306       $rownum = $1;
5307     }
5308     if ($params->{'agentnum'} =~ /^(\d+)$/) {
5309       $agentnum = $1;
5310     }
5311     if ($date and defined($rownum)) {
5312       my $report = FS::Report::FCC_477->report($section,
5313         'date'      => $date,
5314         'agentnum'  => $agentnum,
5315         'detail'    => 1
5316       );
5317       my $pkgnums = $report->{detail}->[$rownum]
5318         or die "row $rownum is past the end of the report";
5319         # '0' so that if there are no pkgnums (empty string) it will create
5320         # a valid query that returns nothing
5321       warn "PKGNUMS:\n$pkgnums\n\n"; # XXX debug
5322
5323       # and this overrides everything
5324       @where = ( "cust_pkg.pkgnum IN($pkgnums)" );
5325     } # else we're missing some params, ignore the whole business
5326   }
5327
5328   ##
5329   # setup queries, links, subs, etc. for the search
5330   ##
5331
5332   # here is the agent virtualization
5333   if ($params->{CurrentUser}) {
5334     my $access_user =
5335       qsearchs('access_user', { username => $params->{CurrentUser} });
5336
5337     if ($access_user) {
5338       push @where, $access_user->agentnums_sql('table'=>'cust_main');
5339     } else {
5340       push @where, "1=0";
5341     }
5342   } else {
5343     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
5344   }
5345
5346   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
5347
5348   my $addl_from = 'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
5349                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
5350                   'LEFT JOIN cust_location USING ( locationnum ) '.
5351                   FS::UI::Web::join_cust_main('cust_pkg', 'cust_pkg');
5352
5353   my $select;
5354   my $count_query;
5355   if ( $params->{'select_zip5'} ) {
5356     my $zip = 'cust_location.zip';
5357
5358     $select = "DISTINCT substr($zip,1,5) as zip";
5359     $orderby = "ORDER BY substr($zip,1,5)";
5360     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
5361   } else {
5362     $select = join(', ',
5363                          'cust_pkg.*',
5364                          ( map "part_pkg.$_", qw( pkg freq ) ),
5365                          'pkg_class.classname',
5366                          'cust_main.custnum AS cust_main_custnum',
5367                          FS::UI::Web::cust_sql_fields(
5368                            $params->{'cust_fields'}
5369                          ),
5370                   );
5371     $count_query = 'SELECT COUNT(*)';
5372   }
5373
5374   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
5375
5376   my $sql_query = {
5377     'table'       => 'cust_pkg',
5378     'hashref'     => {},
5379     'select'      => $select,
5380     'extra_sql'   => $extra_sql,
5381     'order_by'    => $orderby,
5382     'addl_from'   => $addl_from,
5383     'count_query' => $count_query,
5384   };
5385
5386 }
5387
5388 =item fcc_477_count
5389
5390 Returns a list of two package counts.  The first is a count of packages
5391 based on the supplied criteria and the second is the count of residential
5392 packages with those same criteria.  Criteria are specified as in the search
5393 method.
5394
5395 =cut
5396
5397 sub fcc_477_count {
5398   my ($class, $params) = @_;
5399
5400   my $sql_query = $class->search( $params );
5401
5402   my $count_sql = delete($sql_query->{'count_query'});
5403   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
5404     or die "couldn't parse count_sql";
5405
5406   my $count_sth = dbh->prepare($count_sql)
5407     or die "Error preparing $count_sql: ". dbh->errstr;
5408   $count_sth->execute
5409     or die "Error executing $count_sql: ". $count_sth->errstr;
5410   my $count_arrayref = $count_sth->fetchrow_arrayref;
5411
5412   return ( @$count_arrayref );
5413
5414 }
5415
5416 =item tax_locationnum_sql
5417
5418 Returns an SQL expression for the tax location for a package, based
5419 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
5420
5421 =cut
5422
5423 sub tax_locationnum_sql {
5424   my $conf = FS::Conf->new;
5425   if ( $conf->exists('tax-pkg_address') ) {
5426     'cust_pkg.locationnum';
5427   }
5428   elsif ( $conf->exists('tax-ship_address') ) {
5429     'cust_main.ship_locationnum';
5430   }
5431   else {
5432     'cust_main.bill_locationnum';
5433   }
5434 }
5435
5436 =item location_sql
5437
5438 Returns a list: the first item is an SQL fragment identifying matching 
5439 packages/customers via location (taking into account shipping and package
5440 address taxation, if enabled), and subsequent items are the parameters to
5441 substitute for the placeholders in that fragment.
5442
5443 =cut
5444
5445 sub location_sql {
5446   my($class, %opt) = @_;
5447   my $ornull = $opt{'ornull'};
5448
5449   my $conf = new FS::Conf;
5450
5451   # '?' placeholders in _location_sql_where
5452   my $x = $ornull ? 3 : 2;
5453   my @bill_param = ( 
5454     ('district')x3,
5455     ('city')x3, 
5456     ('county')x$x,
5457     ('state')x$x,
5458     'country'
5459   );
5460
5461   my $main_where;
5462   my @main_param;
5463   if ( $conf->exists('tax-ship_address') ) {
5464
5465     $main_where = "(
5466          (     ( ship_last IS NULL     OR  ship_last  = '' )
5467            AND ". _location_sql_where('cust_main', '', $ornull ). "
5468          )
5469       OR (       ship_last IS NOT NULL AND ship_last != ''
5470            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
5471          )
5472     )";
5473     #    AND payby != 'COMP'
5474
5475     @main_param = ( @bill_param, @bill_param );
5476
5477   } else {
5478
5479     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
5480     @main_param = @bill_param;
5481
5482   }
5483
5484   my $where;
5485   my @param;
5486   if ( $conf->exists('tax-pkg_address') ) {
5487
5488     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
5489
5490     $where = " (
5491                     ( cust_pkg.locationnum IS     NULL AND $main_where )
5492                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
5493                )
5494              ";
5495     @param = ( @main_param, @bill_param );
5496   
5497   } else {
5498
5499     $where = $main_where;
5500     @param = @main_param;
5501
5502   }
5503
5504   ( $where, @param );
5505
5506 }
5507
5508 #subroutine, helper for location_sql
5509 sub _location_sql_where {
5510   my $table  = shift;
5511   my $prefix = @_ ? shift : '';
5512   my $ornull = @_ ? shift : '';
5513
5514 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
5515
5516   $ornull = $ornull ? ' OR ? IS NULL ' : '';
5517
5518   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
5519   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
5520   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
5521
5522   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
5523
5524 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
5525   "
5526         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
5527     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
5528     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
5529     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
5530     AND   $table.${prefix}country  = ?
5531   ";
5532 }
5533
5534 sub _X_show_zero {
5535   my( $self, $what ) = @_;
5536
5537   my $what_show_zero = $what. '_show_zero';
5538   length($self->$what_show_zero())
5539     ? ($self->$what_show_zero() eq 'Y')
5540     : $self->part_pkg->$what_show_zero();
5541 }
5542
5543 =head1 SUBROUTINES
5544
5545 =over 4
5546
5547 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
5548
5549 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
5550 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
5551
5552 CUSTNUM is a customer (see L<FS::cust_main>)
5553
5554 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5555 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
5556 permitted.
5557
5558 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
5559 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
5560 new billing items.  An error is returned if this is not possible (see
5561 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
5562 parameter.
5563
5564 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5565 newly-created cust_pkg objects.
5566
5567 REFNUM, if specified, will specify the FS::pkg_referral record to be created
5568 and inserted.  Multiple FS::pkg_referral records can be created by
5569 setting I<refnum> to an array reference of refnums or a hash reference with
5570 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
5571 record will be created corresponding to cust_main.refnum.
5572
5573 =cut
5574
5575 sub order {
5576   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
5577
5578   my $conf = new FS::Conf;
5579
5580   # Transactionize this whole mess
5581   local $SIG{HUP} = 'IGNORE';
5582   local $SIG{INT} = 'IGNORE'; 
5583   local $SIG{QUIT} = 'IGNORE';
5584   local $SIG{TERM} = 'IGNORE';
5585   local $SIG{TSTP} = 'IGNORE'; 
5586   local $SIG{PIPE} = 'IGNORE'; 
5587
5588   my $oldAutoCommit = $FS::UID::AutoCommit;
5589   local $FS::UID::AutoCommit = 0;
5590   my $dbh = dbh;
5591
5592   my $error;
5593 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
5594 #  return "Customer not found: $custnum" unless $cust_main;
5595
5596   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
5597     if $DEBUG;
5598
5599   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5600                          @$remove_pkgnum;
5601
5602   my $change = scalar(@old_cust_pkg) != 0;
5603
5604   my %hash = (); 
5605   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
5606
5607     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
5608          " to pkgpart ". $pkgparts->[0]. "\n"
5609       if $DEBUG;
5610
5611     my $err_or_cust_pkg =
5612       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
5613                                 'refnum'  => $refnum,
5614                               );
5615
5616     unless (ref($err_or_cust_pkg)) {
5617       $dbh->rollback if $oldAutoCommit;
5618       return $err_or_cust_pkg;
5619     }
5620
5621     push @$return_cust_pkg, $err_or_cust_pkg;
5622     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5623     return '';
5624
5625   }
5626
5627   # Create the new packages.
5628   foreach my $pkgpart (@$pkgparts) {
5629
5630     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
5631
5632     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
5633                                       pkgpart => $pkgpart,
5634                                       refnum  => $refnum,
5635                                       %hash,
5636                                     };
5637     $error = $cust_pkg->insert( 'change' => $change );
5638     push @$return_cust_pkg, $cust_pkg;
5639
5640     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
5641       my $supp_pkg = FS::cust_pkg->new({
5642           custnum => $custnum,
5643           pkgpart => $link->dst_pkgpart,
5644           refnum  => $refnum,
5645           main_pkgnum => $cust_pkg->pkgnum,
5646           %hash,
5647       });
5648       $error ||= $supp_pkg->insert( 'change' => $change );
5649       push @$return_cust_pkg, $supp_pkg;
5650     }
5651
5652     if ($error) {
5653       $dbh->rollback if $oldAutoCommit;
5654       return $error;
5655     }
5656
5657   }
5658   # $return_cust_pkg now contains refs to all of the newly 
5659   # created packages.
5660
5661   # Transfer services and cancel old packages.
5662   foreach my $old_pkg (@old_cust_pkg) {
5663
5664     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
5665       if $DEBUG;
5666
5667     foreach my $new_pkg (@$return_cust_pkg) {
5668       $error = $old_pkg->transfer($new_pkg);
5669       if ($error and $error == 0) {
5670         # $old_pkg->transfer failed.
5671         $dbh->rollback if $oldAutoCommit;
5672         return $error;
5673       }
5674     }
5675
5676     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
5677       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
5678       foreach my $new_pkg (@$return_cust_pkg) {
5679         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
5680         if ($error and $error == 0) {
5681           # $old_pkg->transfer failed.
5682         $dbh->rollback if $oldAutoCommit;
5683         return $error;
5684         }
5685       }
5686     }
5687
5688     if ($error > 0) {
5689       # Transfers were successful, but we went through all of the 
5690       # new packages and still had services left on the old package.
5691       # We can't cancel the package under the circumstances, so abort.
5692       $dbh->rollback if $oldAutoCommit;
5693       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
5694     }
5695     $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 );
5696     if ($error) {
5697       $dbh->rollback;
5698       return $error;
5699     }
5700   }
5701   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5702   '';
5703 }
5704
5705 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
5706
5707 A bulk change method to change packages for multiple customers.
5708
5709 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5710 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
5711 permitted.
5712
5713 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5714 replace.  The services (see L<FS::cust_svc>) are moved to the
5715 new billing items.  An error is returned if this is not possible (see
5716 L<FS::pkg_svc>).
5717
5718 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5719 newly-created cust_pkg objects.
5720
5721 =cut
5722
5723 sub bulk_change {
5724   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5725
5726   # Transactionize this whole mess
5727   local $SIG{HUP} = 'IGNORE';
5728   local $SIG{INT} = 'IGNORE'; 
5729   local $SIG{QUIT} = 'IGNORE';
5730   local $SIG{TERM} = 'IGNORE';
5731   local $SIG{TSTP} = 'IGNORE'; 
5732   local $SIG{PIPE} = 'IGNORE'; 
5733
5734   my $oldAutoCommit = $FS::UID::AutoCommit;
5735   local $FS::UID::AutoCommit = 0;
5736   my $dbh = dbh;
5737
5738   my @errors;
5739   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5740                          @$remove_pkgnum;
5741
5742   while(scalar(@old_cust_pkg)) {
5743     my @return = ();
5744     my $custnum = $old_cust_pkg[0]->custnum;
5745     my (@remove) = map { $_->pkgnum }
5746                    grep { $_->custnum == $custnum } @old_cust_pkg;
5747     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5748
5749     my $error = order $custnum, $pkgparts, \@remove, \@return;
5750
5751     push @errors, $error
5752       if $error;
5753     push @$return_cust_pkg, @return;
5754   }
5755
5756   if (scalar(@errors)) {
5757     $dbh->rollback if $oldAutoCommit;
5758     return join(' / ', @errors);
5759   }
5760
5761   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5762   '';
5763 }
5764
5765 =item forward_emails
5766
5767 Returns a hash of svcnums and corresponding email addresses
5768 for svc_acct services that can be used as source or dest
5769 for svc_forward services provisioned in this package.
5770
5771 Accepts options I<svc_forward> OR I<svcnum> for a svc_forward
5772 service;  if included, will ensure the current values of the
5773 specified service are included in the list, even if for some
5774 other reason they wouldn't be.  If called as a class method
5775 with a specified service, returns only these current values.
5776
5777 Caution: does not actually check if svc_forward services are
5778 available to be provisioned on this package.
5779
5780 =cut
5781
5782 sub forward_emails {
5783   my $self = shift;
5784   my %opt = @_;
5785
5786   #load optional service, thoroughly validated
5787   die "Use svcnum or svc_forward, not both"
5788     if $opt{'svcnum'} && $opt{'svc_forward'};
5789   my $svc_forward = $opt{'svc_forward'};
5790   $svc_forward ||= qsearchs('svc_forward',{ 'svcnum' => $opt{'svcnum'} })
5791     if $opt{'svcnum'};
5792   die "Specified service is not a forward service"
5793     if $svc_forward && (ref($svc_forward) ne 'FS::svc_forward');
5794   die "Specified service not found"
5795     if ($opt{'svcnum'} || $opt{'svc_forward'}) && !$svc_forward;
5796
5797   my %email;
5798
5799   ## everything below was basically copied from httemplate/edit/svc_forward.cgi 
5800   ## with minimal refactoring, not sure why we can't just load all svc_accts for this custnum
5801
5802   #add current values from specified service, if there was one
5803   if ($svc_forward) {
5804     foreach my $method (qw( srcsvc_acct dstsvc_acct )) {
5805       my $svc_acct = $svc_forward->$method();
5806       $email{$svc_acct->svcnum} = $svc_acct->email if $svc_acct;
5807     }
5808   }
5809
5810   if (ref($self) eq 'FS::cust_pkg') {
5811
5812     #and including the rest for this customer
5813     my($u_part_svc,@u_acct_svcparts);
5814     foreach $u_part_svc ( qsearch('part_svc',{'svcdb'=>'svc_acct'}) ) {
5815       push @u_acct_svcparts,$u_part_svc->getfield('svcpart');
5816     }
5817
5818     my $custnum = $self->getfield('custnum');
5819     foreach my $i_cust_pkg ( qsearch('cust_pkg',{'custnum'=>$custnum}) ) {
5820       my $cust_pkgnum = $i_cust_pkg->getfield('pkgnum');
5821       #now find the corresponding record(s) in cust_svc (for this pkgnum!)
5822       foreach my $acct_svcpart (@u_acct_svcparts) {
5823         foreach my $i_cust_svc (
5824           qsearch( 'cust_svc', { 'pkgnum'  => $cust_pkgnum,
5825                                  'svcpart' => $acct_svcpart } )
5826         ) {
5827           my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $i_cust_svc->svcnum } );
5828           $email{$svc_acct->svcnum} = $svc_acct->email;
5829         }  
5830       }
5831     }
5832   }
5833
5834   return %email;
5835 }
5836
5837 # Used by FS::Upgrade to migrate to a new database.
5838 sub _upgrade_data {  # class method
5839   my ($class, %opts) = @_;
5840   $class->_upgrade_otaker(%opts);
5841   my @statements = (
5842     # RT#10139, bug resulting in contract_end being set when it shouldn't
5843   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5844     # RT#10830, bad calculation of prorate date near end of year
5845     # the date range for bill is December 2009, and we move it forward
5846     # one year if it's before the previous bill date (which it should 
5847     # never be)
5848   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5849   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5850   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5851     # RT6628, add order_date to cust_pkg
5852     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5853         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5854         history_action = \'insert\') where order_date is null',
5855   );
5856   foreach my $sql (@statements) {
5857     my $sth = dbh->prepare($sql);
5858     $sth->execute or die $sth->errstr;
5859   }
5860
5861   # RT31194: supplemental package links that are deleted don't clean up 
5862   # linked records
5863   my @pkglinknums = qsearch({
5864       'select'    => 'DISTINCT cust_pkg.pkglinknum',
5865       'table'     => 'cust_pkg',
5866       'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ',
5867       'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL 
5868                         AND part_pkg_link.pkglinknum IS NULL',
5869   });
5870   foreach (@pkglinknums) {
5871     my $pkglinknum = $_->pkglinknum;
5872     warn "cleaning part_pkg_link #$pkglinknum\n";
5873     my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum});
5874     my $error = $part_pkg_link->remove_linked;
5875     die $error if $error;
5876   }
5877 }
5878
5879 =back
5880
5881 =head1 BUGS
5882
5883 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5884
5885 In sub order, the @pkgparts array (passed by reference) is clobbered.
5886
5887 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5888 method to pass dates to the recur_prog expression, it should do so.
5889
5890 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5891 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5892 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5893 configuration values.  Probably need a subroutine which decides what to do
5894 based on whether or not we've fetched the user yet, rather than a hash.  See
5895 FS::UID and the TODO.
5896
5897 Now that things are transactional should the check in the insert method be
5898 moved to check ?
5899
5900 =head1 SEE ALSO
5901
5902 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5903 L<FS::pkg_svc>, schema.html from the base documentation
5904
5905 =cut
5906
5907 1;
5908