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