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