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