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