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