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