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