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