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