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