and still create credit source records on 4.x+, #42729
[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
1814         # but if the cancellation happens during the interval, prorate it:
1815         # (XXX obey prorate_round_day here?)
1816         if ( $cust_bill_pkg->sdate < $time ) {
1817           $amount = $amount *
1818                       ($edate - $time) / ($edate - $cust_bill_pkg->sdate);
1819         }
1820
1821         $amount = sprintf('%.2f', $amount);
1822
1823         push @billpkgnums, $cust_bill_pkg->billpkgnum;
1824         push @amounts,     $amount;
1825         push @setuprecurs, 'recur';
1826
1827         warn "Crediting for $amount on package ".$remain_pkg->pkgnum."\n"
1828           if $DEBUG;
1829
1830       }
1831
1832       last if @charges;
1833     }
1834
1835     if ( my $changed_from_pkgnum = $remain_pkg->change_pkgnum ) {
1836       $remain_pkg = FS::cust_pkg->by_key($changed_from_pkgnum);
1837     } else {
1838       # the package has really never been billed
1839       return;
1840     }
1841   }
1842
1843   # keep traditional behavior here. 
1844   local $@;
1845   my $reason = FS::reason->new_or_existing(
1846     reason  => 'Credit for unused time on '. $self->part_pkg->pkg,
1847     type    => $reason_type,
1848     class   => 'R',
1849   );
1850   if ( $@ ) {
1851     return "failed to set credit reason: $@";
1852   }
1853
1854   my $error = FS::cust_credit->credit_lineitems(
1855     'billpkgnums' => \@billpkgnums,
1856     'setuprecurs' => \@setuprecurs,
1857     'amounts'     => \@amounts,
1858     'custnum'     => $self->custnum,
1859     'date'        => time,
1860     'reasonnum'   => $reason->reasonnum,
1861     'apply'       => 1,
1862     'set_source'  => 1,
1863   );
1864
1865   '';
1866 }
1867
1868 =item unsuspend [ OPTION => VALUE ... ]
1869
1870 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1871 package, then unsuspends the package itself (clears the susp field and the
1872 adjourn field if it is in the past).  If the suspend reason includes an 
1873 unsuspension package, that package will be ordered.
1874
1875 Available options are:
1876
1877 =over 4
1878
1879 =item date
1880
1881 Can be set to a date to unsuspend the package in the future (the 'resume' 
1882 field).
1883
1884 =item adjust_next_bill
1885
1886 Can be set true to adjust the next bill date forward by
1887 the amount of time the account was inactive.  This was set true by default
1888 in the past (from 1.4.2 and 1.5.0pre6 through 1.7.0), but now needs to be
1889 explicitly requested with this option or in the price plan.
1890
1891 =back
1892
1893 If there is an error, returns the error, otherwise returns false.
1894
1895 =cut
1896
1897 sub unsuspend {
1898   my( $self, %opt ) = @_;
1899   my $error;
1900
1901   # pass all suspend/cancel actions to the main package
1902   if ( $self->main_pkgnum and !$opt{'from_main'} ) {
1903     return $self->main_pkg->unsuspend(%opt);
1904   }
1905
1906   my $oldAutoCommit = $FS::UID::AutoCommit;
1907   local $FS::UID::AutoCommit = 0;
1908   my $dbh = dbh;
1909
1910   my $old = $self->select_for_update;
1911
1912   my $pkgnum = $old->pkgnum;
1913   if ( $old->get('cancel') || $self->get('cancel') ) {
1914     $dbh->rollback if $oldAutoCommit;
1915     return "Can't unsuspend cancelled package $pkgnum";
1916   }
1917
1918   unless ( $old->get('susp') && $self->get('susp') ) {
1919     $dbh->rollback if $oldAutoCommit;
1920     return "";  # no error                     # complain instead?
1921   }
1922
1923   # handle the case of setting a future unsuspend (resume) date
1924   # and do not continue to actually unsuspend the package
1925   my $date = $opt{'date'};
1926   if ( $date and $date > time ) { # return an error if $date <= time?
1927
1928     if ( $old->get('expire') && $old->get('expire') < $date ) {
1929       $dbh->rollback if $oldAutoCommit;
1930       return "Package $pkgnum expires before it would be unsuspended.";
1931     }
1932
1933     my $new = new FS::cust_pkg { $self->hash };
1934     $new->set('resume', $date);
1935     $error = $new->replace($self, options => $self->options);
1936
1937     if ( $error ) {
1938       $dbh->rollback if $oldAutoCommit;
1939       return $error;
1940     }
1941     else {
1942       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1943       return '';
1944     }
1945   
1946   } #if $date 
1947
1948   if (!$self->setup) {
1949     # then this package is being released from on-hold status
1950     $error = $self->set_initial_timers;
1951     if ( $error ) {
1952       $dbh->rollback if $oldAutoCommit;
1953       return $error;
1954     }
1955   }
1956
1957   my @labels = ();
1958
1959   foreach my $cust_svc (
1960     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
1961   ) {
1962     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1963
1964     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1965       $dbh->rollback if $oldAutoCommit;
1966       return "Illegal svcdb value in part_svc!";
1967     };
1968     my $svcdb = $1;
1969     require "FS/$svcdb.pm";
1970
1971     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1972     if ($svc) {
1973       $error = $svc->unsuspend;
1974       if ( $error ) {
1975         $dbh->rollback if $oldAutoCommit;
1976         return $error;
1977       }
1978       my( $label, $value ) = $cust_svc->label;
1979       push @labels, "$label: $value";
1980     }
1981
1982   }
1983
1984   my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
1985   my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
1986
1987   my %hash = $self->hash;
1988   my $inactive = time - $hash{'susp'};
1989
1990   my $conf = new FS::Conf;
1991
1992   #adjust the next bill date forward
1993   # increment next bill date if certain conditions are met:
1994   # - it was due to be billed at some point
1995   # - either the global or local config says to do this
1996   my $adjust_bill = 0;
1997   if (
1998        $inactive > 0
1999     && ( $hash{'bill'} || $hash{'setup'} )
2000     && (    $opt{'adjust_next_bill'}
2001          || $conf->exists('unsuspend-always_adjust_next_bill_date')
2002          || $self->part_pkg->option('unsuspend_adjust_bill', 1)
2003        )
2004   ) {
2005     $adjust_bill = 1;
2006   }
2007
2008   # but not if:
2009   # - the package billed during suspension
2010   # - or it was ordered on hold
2011   # - or the customer was credited for the unused time
2012
2013   if ( $self->option('suspend_bill',1)
2014       or ( $self->part_pkg->option('suspend_bill',1)
2015            and ! $self->option('no_suspend_bill',1)
2016          )
2017       or $hash{'order_date'} == $hash{'susp'}
2018   ) {
2019     $adjust_bill = 0;
2020   }
2021
2022   if ( $adjust_bill ) {
2023     if (    $self->part_pkg->option('unused_credit_suspend')
2024          or ( ref($reason) and $reason->unused_credit ) ) {
2025       # then the customer was credited for the unused time before suspending,
2026       # so their next bill should be immediate 
2027       $hash{'bill'} = time;
2028     } else {
2029       # add the length of time suspended to the bill date
2030       $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
2031     }
2032   }
2033
2034   $hash{'susp'} = '';
2035   $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time;
2036   $hash{'resume'} = '' if !$hash{'adjourn'};
2037   my $new = new FS::cust_pkg ( \%hash );
2038   $error = $new->replace( $self, options => { $self->options } );
2039   if ( $error ) {
2040     $dbh->rollback if $oldAutoCommit;
2041     return $error;
2042   }
2043
2044   my $unsusp_pkg;
2045
2046   if ( $reason ) {
2047     if ( $reason->unsuspend_pkgpart ) {
2048       warn "Suspend reason '".$reason->reason."' uses deprecated unsuspend_pkgpart feature.\n";
2049       my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
2050         or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
2051                     " not found.";
2052       my $start_date = $self->cust_main->next_bill_date 
2053         if $reason->unsuspend_hold;
2054
2055       if ( $part_pkg ) {
2056         $unsusp_pkg = FS::cust_pkg->new({
2057             'custnum'     => $self->custnum,
2058             'pkgpart'     => $reason->unsuspend_pkgpart,
2059             'start_date'  => $start_date,
2060             'locationnum' => $self->locationnum,
2061             # discount? probably not...
2062         });
2063
2064         $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
2065       }
2066     }
2067     # new way, using fees
2068     if ( $reason->feepart and $reason->fee_on_unsuspend ) {
2069       # register the need to charge a fee, cust_main->bill will do the rest
2070       warn "registering unsuspend fee: pkgnum ".$self->pkgnum.", feepart ".$reason->feepart."\n"
2071         if $DEBUG;
2072       my $cust_pkg_reason_fee = FS::cust_pkg_reason_fee->new({
2073           'pkgreasonnum'  => $cust_pkg_reason->num,
2074           'pkgnum'        => $self->pkgnum,
2075           'feepart'       => $reason->feepart,
2076           'nextbill'      => $reason->fee_hold,
2077       });
2078       $error ||= $cust_pkg_reason_fee->insert;
2079     }
2080
2081     if ( $error ) {
2082       $dbh->rollback if $oldAutoCommit;
2083       return $error;
2084     }
2085   }
2086
2087   if ( $conf->config('unsuspend_email_admin') ) {
2088  
2089     my $error = send_email(
2090       'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
2091                                  #invoice_from ??? well as good as any
2092       'to'      => $conf->config('unsuspend_email_admin'),
2093       'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended',       'body'    => [
2094         "This is an automatic message from your Freeside installation\n",
2095         "informing you that the following customer package has been unsuspended:\n",
2096         "\n",
2097         'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
2098         'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
2099         ( map { "Service : $_\n" } @labels ),
2100         ($unsusp_pkg ?
2101           "An unsuspension fee was charged: ".
2102             $unsusp_pkg->part_pkg->pkg_comment."\n"
2103           : ''
2104         ),
2105       ],
2106       'custnum' => $self->custnum,
2107       'msgtype' => 'admin',
2108     );
2109
2110     if ( $error ) {
2111       warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
2112            "$error\n";
2113     }
2114
2115   }
2116
2117   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
2118     $error = $supp_pkg->unsuspend(%opt, 'from_main' => 1);
2119     if ( $error ) {
2120       $dbh->rollback if $oldAutoCommit;
2121       return "unsuspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
2122     }
2123   }
2124
2125   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2126
2127   ''; #no errors
2128 }
2129
2130 =item unadjourn
2131
2132 Cancels any pending suspension (sets the adjourn field to null)
2133 for this package and any supplemental packages.
2134
2135 If there is an error, returns the error, otherwise returns false.
2136
2137 =cut
2138
2139 sub unadjourn {
2140   my( $self ) = @_;
2141   my $error;
2142
2143   my $oldAutoCommit = $FS::UID::AutoCommit;
2144   local $FS::UID::AutoCommit = 0;
2145   my $dbh = dbh;
2146
2147   my $old = $self->select_for_update;
2148
2149   my $pkgnum = $old->pkgnum;
2150   if ( $old->get('cancel') || $self->get('cancel') ) {
2151     dbh->rollback if $oldAutoCommit;
2152     return "Can't unadjourn cancelled package $pkgnum";
2153     # or at least it's pointless
2154   }
2155
2156   if ( $old->get('susp') || $self->get('susp') ) {
2157     dbh->rollback if $oldAutoCommit;
2158     return "Can't unadjourn suspended package $pkgnum";
2159     # perhaps this is arbitrary
2160   }
2161
2162   unless ( $old->get('adjourn') && $self->get('adjourn') ) {
2163     dbh->rollback if $oldAutoCommit;
2164     return "";  # no error
2165   }
2166
2167   my %hash = $self->hash;
2168   $hash{'adjourn'} = '';
2169   $hash{'resume'}  = '';
2170   my $new = new FS::cust_pkg ( \%hash );
2171   $error = $new->replace( $self, options => { $self->options } );
2172   if ( $error ) {
2173     $dbh->rollback if $oldAutoCommit;
2174     return $error;
2175   }
2176
2177   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
2178     $error = $supp_pkg->unadjourn;
2179     if ( $error ) {
2180       $dbh->rollback if $oldAutoCommit;
2181       return "unadjourning supplemental pkg#".$supp_pkg->pkgnum.": $error";
2182     }
2183   }
2184
2185   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2186
2187   ''; #no errors
2188
2189 }
2190
2191
2192 =item change HASHREF | OPTION => VALUE ... 
2193
2194 Changes this package: cancels it and creates a new one, with a different
2195 pkgpart or locationnum or both.  All services are transferred to the new
2196 package (no change will be made if this is not possible).
2197
2198 Options may be passed as a list of key/value pairs or as a hash reference.
2199 Options are:
2200
2201 =over 4
2202
2203 =item locationnum
2204
2205 New locationnum, to change the location for this package.
2206
2207 =item cust_location
2208
2209 New FS::cust_location object, to create a new location and assign it
2210 to this package.
2211
2212 =item cust_main
2213
2214 New FS::cust_main object, to create a new customer and assign the new package
2215 to it.
2216
2217 =item pkgpart
2218
2219 New pkgpart (see L<FS::part_pkg>).
2220
2221 =item refnum
2222
2223 New refnum (see L<FS::part_referral>).
2224
2225 =item quantity
2226
2227 New quantity; if unspecified, the new package will have the same quantity
2228 as the old.
2229
2230 =item cust_pkg
2231
2232 "New" (existing) FS::cust_pkg object.  The package's services and other 
2233 attributes will be transferred to this package.
2234
2235 =item keep_dates
2236
2237 Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
2238 susp, adjourn, cancel, expire, and contract_end) to the new package.
2239
2240 =item unprotect_svcs
2241
2242 Normally, change() will rollback and return an error if some services 
2243 can't be transferred (also see the I<cust_pkg-change_svcpart> config option).
2244 If unprotect_svcs is true, this method will transfer as many services as 
2245 it can and then unconditionally cancel the old package.
2246
2247 =item contract_end
2248
2249 If specified, sets this value for the contract_end date on the new package 
2250 (without regard for keep_dates or the usual date-preservation behavior.)
2251 Will throw an error if defined but false;  the UI doesn't allow editing 
2252 this unless it already exists, making removal impossible to undo.
2253
2254 =back
2255
2256 At least one of locationnum, cust_location, pkgpart, refnum, cust_main, or
2257 cust_pkg must be specified (otherwise, what's the point?)
2258
2259 Returns either the new FS::cust_pkg object or a scalar error.
2260
2261 For example:
2262
2263   my $err_or_new_cust_pkg = $old_cust_pkg->change
2264
2265 =cut
2266
2267 #used by change and change_later
2268 #didn't put with documented check methods because it depends on change-specific opts
2269 #and it also possibly edits the value of opts
2270 sub _check_change {
2271   my $self = shift;
2272   my $opt = shift;
2273   if ( defined($opt->{'contract_end'}) ) {
2274     my $current_contract_end = $self->get('contract_end');
2275     unless ($opt->{'contract_end'}) {
2276       if ($current_contract_end) {
2277         return "Cannot remove contract end date when changing packages";
2278       } else {
2279         #shouldn't even pass this option if there's not a current value
2280         #but can be handled gracefully if the option is empty
2281         warn "Contract end date passed unexpectedly";
2282         delete $opt->{'contract_end'};
2283         return '';
2284       }
2285     }
2286     unless ($current_contract_end) {
2287       #option shouldn't be passed, throw error if it's non-empty
2288       return "Cannot add contract end date when changing packages " . $self->pkgnum;
2289     }
2290   }
2291   return '';
2292 }
2293
2294 #some false laziness w/order
2295 sub change {
2296   my $self = shift;
2297   my $opt = ref($_[0]) ? shift : { @_ };
2298
2299   my $conf = new FS::Conf;
2300
2301   # handle contract_end on cust_pkg same as passed option
2302   if ( $opt->{'cust_pkg'} ) {
2303     $opt->{'contract_end'} = $opt->{'cust_pkg'}->contract_end;
2304     delete $opt->{'contract_end'} unless $opt->{'contract_end'};
2305   }
2306
2307   # check contract_end, prevent adding/removing
2308   my $error = $self->_check_change($opt);
2309   return $error if $error;
2310
2311   # Transactionize this whole mess
2312   my $oldAutoCommit = $FS::UID::AutoCommit;
2313   local $FS::UID::AutoCommit = 0;
2314   my $dbh = dbh;
2315
2316   if ( $opt->{'cust_location'} ) {
2317     $error = $opt->{'cust_location'}->find_or_insert;
2318     if ( $error ) {
2319       $dbh->rollback if $oldAutoCommit;
2320       return "creating location record: $error";
2321     }
2322     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
2323   }
2324
2325   # figure out if we're changing pkgpart
2326   if ( $opt->{'cust_pkg'} ) {
2327     $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart;
2328   }
2329
2330   # whether to override pkgpart checking on the new package
2331   my $same_pkgpart = 1;
2332   if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
2333     $same_pkgpart = 0;
2334   }
2335
2336   # Before going any further here: if the package is still in the pre-setup
2337   # state, it's safe to modify it in place. No need to charge/credit for 
2338   # partial period, transfer usage pools, copy invoice details, or change any
2339   # dates. We DO need to "transfer" services (from the package to itself) to
2340   # check their validity on the new pkgpart.
2341   if ( ! $self->setup and ! $opt->{cust_pkg} and ! $opt->{cust_main} ) {
2342     foreach ( qw( locationnum pkgpart quantity refnum salesnum ) ) {
2343       if ( length($opt->{$_}) ) {
2344         $self->set($_, $opt->{$_});
2345       }
2346     }
2347     # almost. if the new pkgpart specifies start/adjourn/expire timers, 
2348     # apply those.
2349     if ( !$same_pkgpart ) {
2350       $error ||= $self->set_initial_timers;
2351     }
2352     # but if contract_end was explicitly specified, that overrides all else
2353     $self->set('contract_end', $opt->{'contract_end'})
2354       if $opt->{'contract_end'};
2355
2356     $error ||= $self->replace;
2357     if ( $error ) {
2358       $dbh->rollback if $oldAutoCommit;
2359       return "modifying package: $error";
2360     }
2361
2362     # check/convert services (only on pkgpart change, to avoid surprises
2363     # when editing locations)
2364     # (maybe do this if changing quantity?)
2365     if ( !$same_pkgpart ) {
2366
2367       $error = $self->transfer($self);
2368
2369       if ( $error and $error == 0 ) {
2370         $error = "transferring $error";
2371       } elsif ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2372         warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2373         $error = $self->transfer($self, 'change_svcpart'=>1 );
2374         if ($error and $error == 0) {
2375           $error = "converting $error";
2376         }
2377       }
2378
2379       if ($error > 0) {
2380         $error = "unable to transfer all services";
2381       }
2382
2383       if ( $error ) {
2384         $dbh->rollback if $oldAutoCommit;
2385         return $error;
2386       }
2387
2388     } # done transferring services
2389
2390     $dbh->commit if $oldAutoCommit;
2391     return $self;
2392
2393   }
2394
2395   my %hash = (); 
2396
2397   my $time = time;
2398
2399   $hash{'setup'} = $time if $self->get('setup');
2400
2401   $hash{'change_date'} = $time;
2402   $hash{"change_$_"}  = $self->$_()
2403     foreach qw( pkgnum pkgpart locationnum );
2404
2405   my $unused_credit = 0;
2406   my $keep_dates = $opt->{'keep_dates'};
2407
2408   # Special case.  If the pkgpart is changing, and the customer is going to be
2409   # credited for remaining time, don't keep setup, bill, or last_bill dates,
2410   # and DO pass the flag to cancel() to credit the customer.  If the old
2411   # package had a setup date, set the new package's setup to the package
2412   # change date so that it has the same status as before.
2413   if ( $opt->{'pkgpart'} 
2414        and $opt->{'pkgpart'} != $self->pkgpart
2415        and $self->part_pkg->option('unused_credit_change', 1) ) {
2416     $unused_credit = 1;
2417     $keep_dates = 0;
2418     $hash{'last_bill'} = '';
2419     $hash{'bill'} = '';
2420   }
2421
2422   if ( $keep_dates ) {
2423     foreach my $date ( qw(setup bill last_bill) ) {
2424       $hash{$date} = $self->getfield($date);
2425     }
2426   }
2427   # always keep the following dates
2428   foreach my $date (qw(order_date susp adjourn cancel expire resume 
2429                     start_date contract_end)) {
2430     $hash{$date} = $self->getfield($date);
2431   }
2432   # but if contract_end was explicitly specified, that overrides all else
2433   $hash{'contract_end'} = $opt->{'contract_end'}
2434     if $opt->{'contract_end'};
2435
2436   # allow $opt->{'locationnum'} = '' to specifically set it to null
2437   # (i.e. customer default location)
2438   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2439
2440   # usually this doesn't matter.  the two cases where it does are:
2441   # 1. unused_credit_change + pkgpart change + setup fee on the new package
2442   # and
2443   # 2. (more importantly) changing a package before it's billed
2444   $hash{'waive_setup'} = $self->waive_setup;
2445
2446   # if this package is scheduled for a future package change, preserve that
2447   $hash{'change_to_pkgnum'} = $self->change_to_pkgnum;
2448
2449   my $custnum = $self->custnum;
2450   if ( $opt->{cust_main} ) {
2451     my $cust_main = $opt->{cust_main};
2452     unless ( $cust_main->custnum ) { 
2453       my $error = $cust_main->insert( @{ $opt->{cust_main_insert_args}||[] } );
2454       if ( $error ) {
2455         $dbh->rollback if $oldAutoCommit;
2456         return "inserting customer record: $error";
2457       }
2458     }
2459     $custnum = $cust_main->custnum;
2460   }
2461
2462   $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
2463
2464   my $cust_pkg;
2465   if ( $opt->{'cust_pkg'} ) {
2466     # The target package already exists; update it to show that it was 
2467     # changed from this package.
2468     $cust_pkg = $opt->{'cust_pkg'};
2469
2470     # follow all the above rules for date changes, etc.
2471     foreach (keys %hash) {
2472       $cust_pkg->set($_, $hash{$_});
2473     }
2474     # except those that implement the future package change behavior
2475     foreach (qw(change_to_pkgnum start_date expire)) {
2476       $cust_pkg->set($_, '');
2477     }
2478
2479     $error = $cust_pkg->replace;
2480
2481   } else {
2482     # Create the new package.
2483     $cust_pkg = new FS::cust_pkg {
2484       custnum     => $custnum,
2485       locationnum => $opt->{'locationnum'},
2486       ( map {  $_ => ( $opt->{$_} || $self->$_() )  }
2487           qw( pkgpart quantity refnum salesnum )
2488       ),
2489       %hash,
2490     };
2491     $error = $cust_pkg->insert( 'change' => 1,
2492                                 'allow_pkgpart' => $same_pkgpart );
2493   }
2494   if ($error) {
2495     $dbh->rollback if $oldAutoCommit;
2496     return "inserting new package: $error";
2497   }
2498
2499   # Transfer services and cancel old package.
2500   # Enforce service limits only if this is a pkgpart change.
2501   local $FS::cust_svc::ignore_quantity;
2502   $FS::cust_svc::ignore_quantity = 1 if $same_pkgpart;
2503   $error = $self->transfer($cust_pkg);
2504   if ($error and $error == 0) {
2505     # $old_pkg->transfer failed.
2506     $dbh->rollback if $oldAutoCommit;
2507     return "transferring $error";
2508   }
2509
2510   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2511     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2512     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
2513     if ($error and $error == 0) {
2514       # $old_pkg->transfer failed.
2515       $dbh->rollback if $oldAutoCommit;
2516       return "converting $error";
2517     }
2518   }
2519
2520   # We set unprotect_svcs when executing a "future package change".  It's 
2521   # not a user-interactive operation, so returning an error means the 
2522   # package change will just fail.  Rather than have that happen, we'll 
2523   # let leftover services be deleted.
2524   if ($error > 0 and !$opt->{'unprotect_svcs'}) {
2525     # Transfers were successful, but we still had services left on the old
2526     # package.  We can't change the package under this circumstances, so abort.
2527     $dbh->rollback if $oldAutoCommit;
2528     return "unable to transfer all services";
2529   }
2530
2531   #reset usage if changing pkgpart
2532   # AND usage rollover is off (otherwise adds twice, now and at package bill)
2533   if ($self->pkgpart != $cust_pkg->pkgpart) {
2534     my $part_pkg = $cust_pkg->part_pkg;
2535     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
2536                                                  ? ()
2537                                                  : ( 'null' => 1 )
2538                                    )
2539       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
2540
2541     if ($error) {
2542       $dbh->rollback if $oldAutoCommit;
2543       return "setting usage values: $error";
2544     }
2545   } else {
2546     # if NOT changing pkgpart, transfer any usage pools over
2547     foreach my $usage ($self->cust_pkg_usage) {
2548       $usage->set('pkgnum', $cust_pkg->pkgnum);
2549       $error = $usage->replace;
2550       if ( $error ) {
2551         $dbh->rollback if $oldAutoCommit;
2552         return "transferring usage pools: $error";
2553       }
2554     }
2555   }
2556
2557   # transfer usage pricing add-ons, if we're not changing pkgpart or if they were specified
2558   if ( $same_pkgpart || $opt->{'cust_pkg_usageprice'}) {
2559     my @old_cust_pkg_usageprice;
2560     if ($opt->{'cust_pkg_usageprice'}) {
2561       @old_cust_pkg_usageprice = @{ $opt->{'cust_pkg_usageprice'} };
2562     } else {
2563       @old_cust_pkg_usageprice = $self->cust_pkg_usageprice;
2564     }
2565     foreach my $old_cust_pkg_usageprice (@old_cust_pkg_usageprice) {
2566       my $new_cust_pkg_usageprice = new FS::cust_pkg_usageprice {
2567         'pkgnum'         => $cust_pkg->pkgnum,
2568         'usagepricepart' => $old_cust_pkg_usageprice->usagepricepart,
2569         'quantity'       => $old_cust_pkg_usageprice->quantity,
2570       };
2571       $error = $new_cust_pkg_usageprice->insert;
2572       if ( $error ) {
2573         $dbh->rollback if $oldAutoCommit;
2574         return "Error transferring usage pricing add-on: $error";
2575       }
2576     }
2577   }
2578
2579   # transfer discounts, if we're not changing pkgpart
2580   if ( $same_pkgpart ) {
2581     foreach my $old_discount ($self->cust_pkg_discount_active) {
2582       # don't remove the old discount, we may still need to bill that package.
2583       my $new_discount = new FS::cust_pkg_discount {
2584         'pkgnum'      => $cust_pkg->pkgnum,
2585         'discountnum' => $old_discount->discountnum,
2586         'months_used' => $old_discount->months_used,
2587       };
2588       $error = $new_discount->insert;
2589       if ( $error ) {
2590         $dbh->rollback if $oldAutoCommit;
2591         return "transferring discounts: $error";
2592       }
2593     }
2594   }
2595
2596   # transfer (copy) invoice details
2597   foreach my $detail ($self->cust_pkg_detail) {
2598     my $new_detail = FS::cust_pkg_detail->new({ $detail->hash });
2599     $new_detail->set('pkgdetailnum', '');
2600     $new_detail->set('pkgnum', $cust_pkg->pkgnum);
2601     $error = $new_detail->insert;
2602     if ( $error ) {
2603       $dbh->rollback if $oldAutoCommit;
2604       return "transferring package notes: $error";
2605     }
2606   }
2607
2608   # transfer scheduled expire/adjourn reasons
2609   foreach my $action ('expire', 'adjourn') {
2610     if ( $cust_pkg->get($action) ) {
2611       my $reason = $self->last_cust_pkg_reason($action);
2612       if ( $reason ) {
2613         $reason->set('pkgnum', $cust_pkg->pkgnum);
2614         $error = $reason->replace;
2615         if ( $error ) {
2616           $dbh->rollback if $oldAutoCommit;
2617           return "transferring $action reason: $error";
2618         }
2619       }
2620     }
2621   }
2622   
2623   my @new_supp_pkgs;
2624
2625   if ( !$opt->{'cust_pkg'} ) {
2626     # Order any supplemental packages.
2627     my $part_pkg = $cust_pkg->part_pkg;
2628     my @old_supp_pkgs = $self->supplemental_pkgs;
2629     foreach my $link ($part_pkg->supp_part_pkg_link) {
2630       my $old;
2631       foreach (@old_supp_pkgs) {
2632         if ($_->pkgpart == $link->dst_pkgpart) {
2633           $old = $_;
2634           $_->pkgpart(0); # so that it can't match more than once
2635         }
2636         last if $old;
2637       }
2638       # false laziness with FS::cust_main::Packages::order_pkg
2639       my $new = FS::cust_pkg->new({
2640           pkgpart       => $link->dst_pkgpart,
2641           pkglinknum    => $link->pkglinknum,
2642           custnum       => $custnum,
2643           main_pkgnum   => $cust_pkg->pkgnum,
2644           locationnum   => $cust_pkg->locationnum,
2645           start_date    => $cust_pkg->start_date,
2646           order_date    => $cust_pkg->order_date,
2647           expire        => $cust_pkg->expire,
2648           adjourn       => $cust_pkg->adjourn,
2649           contract_end  => $cust_pkg->contract_end,
2650           refnum        => $cust_pkg->refnum,
2651           discountnum   => $cust_pkg->discountnum,
2652           waive_setup   => $cust_pkg->waive_setup,
2653       });
2654       if ( $old and $opt->{'keep_dates'} ) {
2655         foreach (qw(setup bill last_bill)) {
2656           $new->set($_, $old->get($_));
2657         }
2658       }
2659       $error = $new->insert( allow_pkgpart => $same_pkgpart );
2660       # transfer services
2661       if ( $old ) {
2662         $error ||= $old->transfer($new);
2663       }
2664       if ( $error and $error > 0 ) {
2665         # no reason why this should ever fail, but still...
2666         $error = "Unable to transfer all services from supplemental package ".
2667           $old->pkgnum;
2668       }
2669       if ( $error ) {
2670         $dbh->rollback if $oldAutoCommit;
2671         return $error;
2672       }
2673       push @new_supp_pkgs, $new;
2674     }
2675   } # if !$opt->{'cust_pkg'}
2676     # because if there is one, then supplemental packages would already
2677     # have been created for it.
2678
2679   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
2680   #remaining time.
2681   #Don't allow billing the package (preceding period packages and/or 
2682   #outstanding usage) if we are keeping dates (i.e. location changing), 
2683   #because the new package will be billed for the same date range.
2684   #Supplemental packages are also canceled here.
2685
2686   # during scheduled changes, avoid canceling the package we just
2687   # changed to (duh)
2688   $self->set('change_to_pkgnum' => '');
2689
2690   $error = $self->cancel(
2691     quiet          => 1, 
2692     unused_credit  => $unused_credit,
2693     nobill         => $keep_dates,
2694     change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
2695     no_delay_cancel => 1,
2696   );
2697   if ($error) {
2698     $dbh->rollback if $oldAutoCommit;
2699     return "canceling old package: $error";
2700   }
2701
2702   # transfer rt_field_charge, if we're not changing pkgpart
2703   # after billing of old package, before billing of new package
2704   if ( $same_pkgpart ) {
2705     foreach my $rt_field_charge ($self->rt_field_charge) {
2706       $rt_field_charge->set('pkgnum', $cust_pkg->pkgnum);
2707       $error = $rt_field_charge->replace;
2708       if ( $error ) {
2709         $dbh->rollback if $oldAutoCommit;
2710         return "transferring rt_field_charge: $error";
2711       }
2712     }
2713   }
2714
2715   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
2716     #$self->cust_main
2717     my $error = $cust_pkg->cust_main->bill( 
2718       'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
2719     );
2720     if ( $error ) {
2721       $dbh->rollback if $oldAutoCommit;
2722       return "billing new package: $error";
2723     }
2724   }
2725
2726   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2727
2728   $cust_pkg;
2729
2730 }
2731
2732 =item change_later OPTION => VALUE...
2733
2734 Schedule a package change for a later date.  This actually orders the new
2735 package immediately, but sets its start date for a future date, and sets
2736 the current package to expire on the same date.
2737
2738 If the package is already scheduled for a change, this can be called with 
2739 'start_date' to change the scheduled date, or with pkgpart and/or 
2740 locationnum to modify the package change.  To cancel the scheduled change 
2741 entirely, see C<abort_change>.
2742
2743 Options include:
2744
2745 =over 4
2746
2747 =item start_date
2748
2749 The date for the package change.  Required, and must be in the future.
2750
2751 =item pkgpart
2752
2753 =item locationnum
2754
2755 =item quantity
2756
2757 =item contract_end
2758
2759 The pkgpart, locationnum, quantity and optional contract_end of the new 
2760 package, with the same meaning as in C<change>.
2761
2762 =back
2763
2764 =cut
2765
2766 sub change_later {
2767   my $self = shift;
2768   my $opt = ref($_[0]) ? shift : { @_ };
2769
2770   # check contract_end, prevent adding/removing
2771   my $error = $self->_check_change($opt);
2772   return $error if $error;
2773
2774   my $oldAutoCommit = $FS::UID::AutoCommit;
2775   local $FS::UID::AutoCommit = 0;
2776   my $dbh = dbh;
2777
2778   my $cust_main = $self->cust_main;
2779
2780   my $date = delete $opt->{'start_date'} or return 'start_date required';
2781  
2782   if ( $date <= time ) {
2783     $dbh->rollback if $oldAutoCommit;
2784     return "start_date $date is in the past";
2785   }
2786
2787   # If the user entered a new location, set it up now.
2788   if ( $opt->{'cust_location'} ) {
2789     $error = $opt->{'cust_location'}->find_or_insert;
2790     if ( $error ) {
2791       $dbh->rollback if $oldAutoCommit;
2792       return "creating location record: $error";
2793     }
2794     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
2795   }
2796
2797   if ( $self->change_to_pkgnum ) {
2798     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
2799     my $new_pkgpart = $opt->{'pkgpart'}
2800         if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart;
2801     my $new_locationnum = $opt->{'locationnum'}
2802         if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum;
2803     my $new_quantity = $opt->{'quantity'}
2804         if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity;
2805     my $new_contract_end = $opt->{'contract_end'}
2806         if $opt->{'contract_end'} and $opt->{'contract_end'} != $change_to->contract_end;
2807     if ( $new_pkgpart or $new_locationnum or $new_quantity or $new_contract_end ) {
2808       # it hasn't been billed yet, so in principle we could just edit
2809       # it in place (w/o a package change), but that's bad form.
2810       # So change the package according to the new options...
2811       my $err_or_pkg = $change_to->change(%$opt);
2812       if ( ref $err_or_pkg ) {
2813         # Then set that package up for a future start.
2814         $self->set('change_to_pkgnum', $err_or_pkg->pkgnum);
2815         $self->set('expire', $date); # in case it's different
2816         $err_or_pkg->set('start_date', $date);
2817         $err_or_pkg->set('change_date', '');
2818         $err_or_pkg->set('change_pkgnum', '');
2819
2820         $error = $self->replace       ||
2821                  $err_or_pkg->replace ||
2822                  #because change() might've edited existing scheduled change in place
2823                  (($err_or_pkg->pkgnum == $change_to->pkgnum) ? '' :
2824                   $change_to->cancel('no_delay_cancel' => 1) ||
2825                   $change_to->delete);
2826       } else {
2827         $error = $err_or_pkg;
2828       }
2829     } else { # change the start date only.
2830       $self->set('expire', $date);
2831       $change_to->set('start_date', $date);
2832       $error = $self->replace || $change_to->replace;
2833     }
2834     if ( $error ) {
2835       $dbh->rollback if $oldAutoCommit;
2836       return $error;
2837     } else {
2838       $dbh->commit if $oldAutoCommit;
2839       return '';
2840     }
2841   } # if $self->change_to_pkgnum
2842
2843   my $new_pkgpart = $opt->{'pkgpart'}
2844       if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart;
2845   my $new_locationnum = $opt->{'locationnum'}
2846       if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum;
2847   my $new_quantity = $opt->{'quantity'}
2848       if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity;
2849   my $new_contract_end = $opt->{'contract_end'}
2850       if $opt->{'contract_end'} and $opt->{'contract_end'} != $self->contract_end;
2851
2852   return '' unless $new_pkgpart or $new_locationnum or $new_quantity or $new_contract_end; # wouldn't do anything
2853
2854   # allow $opt->{'locationnum'} = '' to specifically set it to null
2855   # (i.e. customer default location)
2856   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2857
2858   my $new = FS::cust_pkg->new( {
2859     custnum     => $self->custnum,
2860     locationnum => $opt->{'locationnum'},
2861     start_date  => $date,
2862     map   {  $_ => ( $opt->{$_} || $self->$_() )  }
2863       qw( pkgpart quantity refnum salesnum contract_end )
2864   } );
2865   $error = $new->insert('change' => 1, 
2866                         'allow_pkgpart' => ($new_pkgpart ? 0 : 1));
2867   if ( !$error ) {
2868     $self->set('change_to_pkgnum', $new->pkgnum);
2869     $self->set('expire', $date);
2870     $error = $self->replace;
2871   }
2872   if ( $error ) {
2873     $dbh->rollback if $oldAutoCommit;
2874   } else {
2875     $dbh->commit if $oldAutoCommit;
2876   }
2877
2878   $error;
2879 }
2880
2881 =item abort_change
2882
2883 Cancels a future package change scheduled by C<change_later>.
2884
2885 =cut
2886
2887 sub abort_change {
2888   my $self = shift;
2889   my $oldAutoCommit = $FS::UID::AutoCommit;
2890   local $FS::UID::AutoCommit = 0;
2891
2892   my $pkgnum = $self->change_to_pkgnum;
2893   my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum;
2894   my $error;
2895   $self->set('change_to_pkgnum', '');
2896   $self->set('expire', '');
2897   $error = $self->replace;
2898   if ( $change_to ) {
2899     $error ||= $change_to->cancel || $change_to->delete;
2900   }
2901
2902   if ( $oldAutoCommit ) {
2903     if ( $error ) {
2904       dbh->rollback;
2905     } else {
2906       dbh->commit;
2907     }
2908   }
2909
2910   return $error;
2911 }
2912
2913 =item set_quantity QUANTITY
2914
2915 Change the package's quantity field.  This is one of the few package properties
2916 that can safely be changed without canceling and reordering the package
2917 (because it doesn't affect tax eligibility).  Returns an error or an 
2918 empty string.
2919
2920 =cut
2921
2922 sub set_quantity {
2923   my $self = shift;
2924   $self = $self->replace_old; # just to make sure
2925   $self->quantity(shift);
2926   $self->replace;
2927 }
2928
2929 =item set_salesnum SALESNUM
2930
2931 Change the package's salesnum (sales person) field.  This is one of the few
2932 package properties that can safely be changed without canceling and reordering
2933 the package (because it doesn't affect tax eligibility).  Returns an error or
2934 an empty string.
2935
2936 =cut
2937
2938 sub set_salesnum {
2939   my $self = shift;
2940   $self = $self->replace_old; # just to make sure
2941   $self->salesnum(shift);
2942   $self->replace;
2943   # XXX this should probably reassign any credit that's already been given
2944 }
2945
2946 =item modify_charge OPTIONS
2947
2948 Change the properties of a one-time charge.  The following properties can
2949 be changed this way:
2950 - pkg: the package description
2951 - classnum: the package class
2952 - additional: arrayref of additional invoice details to add to this package
2953
2954 and, I<if the charge has not yet been billed>:
2955 - start_date: the date when it will be billed
2956 - amount: the setup fee to be charged
2957 - quantity: the multiplier for the setup fee
2958 - separate_bill: whether to put the charge on a separate invoice
2959
2960 If you pass 'adjust_commission' => 1, and the classnum changes, and there are
2961 commission credits linked to this charge, they will be recalculated.
2962
2963 =cut
2964
2965 sub modify_charge {
2966   my $self = shift;
2967   my %opt = @_;
2968   my $part_pkg = $self->part_pkg;
2969   my $pkgnum = $self->pkgnum;
2970
2971   my $dbh = dbh;
2972   my $oldAutoCommit = $FS::UID::AutoCommit;
2973   local $FS::UID::AutoCommit = 0;
2974
2975   return "Can't use modify_charge except on one-time charges"
2976     unless $part_pkg->freq eq '0';
2977
2978   if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) {
2979     $part_pkg->set('pkg', $opt{'pkg'});
2980   }
2981
2982   my %pkg_opt = $part_pkg->options;
2983   my $pkg_opt_modified = 0;
2984
2985   $opt{'additional'} ||= [];
2986   my $i;
2987   my @old_additional;
2988   foreach (grep /^additional/, keys %pkg_opt) {
2989     ($i) = ($_ =~ /^additional_info(\d+)$/);
2990     $old_additional[$i] = $pkg_opt{$_} if $i;
2991     delete $pkg_opt{$_};
2992   }
2993
2994   for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) {
2995     $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i];
2996     if (!exists($old_additional[$i])
2997         or $old_additional[$i] ne $opt{'additional'}->[$i])
2998     {
2999       $pkg_opt_modified = 1;
3000     }
3001   }
3002   $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i;
3003   $pkg_opt{'additional_count'} = $i if $i > 0;
3004
3005   my $old_classnum;
3006   if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} )
3007   {
3008     # remember it
3009     $old_classnum = $part_pkg->classnum;
3010     $part_pkg->set('classnum', $opt{'classnum'});
3011   }
3012
3013   if ( !$self->get('setup') ) {
3014     # not yet billed, so allow amount, setup_cost, quantity, start_date,
3015     # and separate_bill
3016
3017     if ( exists($opt{'amount'}) 
3018           and $part_pkg->option('setup_fee') != $opt{'amount'}
3019           and $opt{'amount'} > 0 ) {
3020
3021       $pkg_opt{'setup_fee'} = $opt{'amount'};
3022       $pkg_opt_modified = 1;
3023     }
3024
3025     if ( exists($opt{'setup_cost'}) 
3026           and $part_pkg->setup_cost != $opt{'setup_cost'}
3027           and $opt{'setup_cost'} > 0 ) {
3028
3029       $part_pkg->set('setup_cost', $opt{'setup_cost'});
3030     }
3031
3032     if ( exists($opt{'quantity'})
3033           and $opt{'quantity'} != $self->quantity
3034           and $opt{'quantity'} > 0 ) {
3035         
3036       $self->set('quantity', $opt{'quantity'});
3037     }
3038
3039     if ( exists($opt{'start_date'})
3040           and $opt{'start_date'} != $self->start_date ) {
3041
3042       $self->set('start_date', $opt{'start_date'});
3043     }
3044
3045     if ( exists($opt{'separate_bill'})
3046           and $opt{'separate_bill'} ne $self->separate_bill ) {
3047
3048       $self->set('separate_bill', $opt{'separate_bill'});
3049     }
3050
3051
3052   } # else simply ignore them; the UI shouldn't allow editing the fields
3053
3054   
3055   if ( exists($opt{'taxclass'}) 
3056           and $part_pkg->taxclass ne $opt{'taxclass'}) {
3057     
3058       $part_pkg->set('taxclass', $opt{'taxclass'});
3059   }
3060
3061   my $error;
3062   if ( $part_pkg->modified or $pkg_opt_modified ) {
3063     # can we safely modify the package def?
3064     # Yes, if it's not available for purchase, and this is the only instance
3065     # of it.
3066     if ( $part_pkg->disabled
3067          and FS::cust_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 1
3068          and FS::quotation_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 0
3069        ) {
3070       $error = $part_pkg->replace( options => \%pkg_opt );
3071     } else {
3072       # clone it
3073       $part_pkg = $part_pkg->clone;
3074       $part_pkg->set('disabled' => 'Y');
3075       $error = $part_pkg->insert( options => \%pkg_opt );
3076       # and associate this as yet-unbilled package to the new package def
3077       $self->set('pkgpart' => $part_pkg->pkgpart);
3078     }
3079     if ( $error ) {
3080       $dbh->rollback if $oldAutoCommit;
3081       return $error;
3082     }
3083   }
3084
3085   if ($self->modified) { # for quantity or start_date change, or if we had
3086                          # to clone the existing package def
3087     my $error = $self->replace;
3088     return $error if $error;
3089   }
3090   if (defined $old_classnum) {
3091     # fix invoice grouping records
3092     my $old_catname = $old_classnum
3093                       ? FS::pkg_class->by_key($old_classnum)->categoryname
3094                       : '';
3095     my $new_catname = $opt{'classnum'}
3096                       ? $part_pkg->pkg_class->categoryname
3097                       : '';
3098     if ( $old_catname ne $new_catname ) {
3099       foreach my $cust_bill_pkg ($self->cust_bill_pkg) {
3100         # (there should only be one...)
3101         my @display = qsearch( 'cust_bill_pkg_display', {
3102             'billpkgnum'  => $cust_bill_pkg->billpkgnum,
3103             'section'     => $old_catname,
3104         });
3105         foreach (@display) {
3106           $_->set('section', $new_catname);
3107           $error = $_->replace;
3108           if ( $error ) {
3109             $dbh->rollback if $oldAutoCommit;
3110             return $error;
3111           }
3112         }
3113       } # foreach $cust_bill_pkg
3114     }
3115
3116     if ( $opt{'adjust_commission'} ) {
3117       # fix commission credits...tricky.
3118       foreach my $cust_event ($self->cust_event) {
3119         my $part_event = $cust_event->part_event;
3120         foreach my $table (qw(sales agent)) {
3121           my $class =
3122             "FS::part_event::Action::Mixin::credit_${table}_pkg_class";
3123           my $credit = qsearchs('cust_credit', {
3124               'eventnum' => $cust_event->eventnum,
3125           });
3126           if ( $part_event->isa($class) ) {
3127             # Yes, this results in current commission rates being applied 
3128             # retroactively to a one-time charge.  For accounting purposes 
3129             # there ought to be some kind of time limit on doing this.
3130             my $amount = $part_event->_calc_credit($self);
3131             if ( $credit and $credit->amount ne $amount ) {
3132               # Void the old credit.
3133               $error = $credit->void('Package class changed');
3134               if ( $error ) {
3135                 $dbh->rollback if $oldAutoCommit;
3136                 return "$error (adjusting commission credit)";
3137               }
3138             }
3139             # redo the event action to recreate the credit.
3140             local $@ = '';
3141             eval { $part_event->do_action( $self, $cust_event ) };
3142             if ( $@ ) {
3143               $dbh->rollback if $oldAutoCommit;
3144               return $@;
3145             }
3146           } # if $part_event->isa($class)
3147         } # foreach $table
3148       } # foreach $cust_event
3149     } # if $opt{'adjust_commission'}
3150   } # if defined $old_classnum
3151
3152   $dbh->commit if $oldAutoCommit;
3153   '';
3154 }
3155
3156
3157
3158 use Data::Dumper;
3159 sub process_bulk_cust_pkg {
3160   my $job = shift;
3161   my $param = shift;
3162   warn Dumper($param) if $DEBUG;
3163
3164   my $old_part_pkg = qsearchs('part_pkg', 
3165                               { pkgpart => $param->{'old_pkgpart'} });
3166   my $new_part_pkg = qsearchs('part_pkg',
3167                               { pkgpart => $param->{'new_pkgpart'} });
3168   die "Must select a new package type\n" unless $new_part_pkg;
3169   #my $keep_dates = $param->{'keep_dates'} || 0;
3170   my $keep_dates = 1; # there is no good reason to turn this off
3171
3172   my $oldAutoCommit = $FS::UID::AutoCommit;
3173   local $FS::UID::AutoCommit = 0;
3174   my $dbh = dbh;
3175
3176   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
3177
3178   my $i = 0;
3179   foreach my $old_cust_pkg ( @cust_pkgs ) {
3180     $i++;
3181     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
3182     if ( $old_cust_pkg->getfield('cancel') ) {
3183       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
3184         $old_cust_pkg->pkgnum."\n"
3185         if $DEBUG;
3186       next;
3187     }
3188     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
3189       if $DEBUG;
3190     my $error = $old_cust_pkg->change(
3191       'pkgpart'     => $param->{'new_pkgpart'},
3192       'keep_dates'  => $keep_dates
3193     );
3194     if ( !ref($error) ) { # change returns the cust_pkg on success
3195       $dbh->rollback;
3196       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
3197     }
3198   }
3199   $dbh->commit if $oldAutoCommit;
3200   return;
3201 }
3202
3203 =item last_bill
3204
3205 Returns the last bill date, or if there is no last bill date, the setup date.
3206 Useful for billing metered services.
3207
3208 =cut
3209
3210 sub last_bill {
3211   my $self = shift;
3212   return $self->setfield('last_bill', $_[0]) if @_;
3213   return $self->getfield('last_bill') if $self->getfield('last_bill');
3214   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
3215                                                   'edate'  => $self->bill,  } );
3216   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
3217 }
3218
3219 =item last_cust_pkg_reason ACTION
3220
3221 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
3222 Returns false if there is no reason or the package is not currenly ACTION'd
3223 ACTION is one of adjourn, susp, cancel, or expire.
3224
3225 =cut
3226
3227 sub last_cust_pkg_reason {
3228   my ( $self, $action ) = ( shift, shift );
3229   my $date = $self->get($action);
3230   qsearchs( {
3231               'table' => 'cust_pkg_reason',
3232               'hashref' => { 'pkgnum' => $self->pkgnum,
3233                              'action' => substr(uc($action), 0, 1),
3234                              'date'   => $date,
3235                            },
3236               'order_by' => 'ORDER BY num DESC LIMIT 1',
3237            } );
3238 }
3239
3240 =item last_reason ACTION
3241
3242 Returns the most recent ACTION FS::reason associated with the package.
3243 Returns false if there is no reason or the package is not currenly ACTION'd
3244 ACTION is one of adjourn, susp, cancel, or expire.
3245
3246 =cut
3247
3248 sub last_reason {
3249   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
3250   $cust_pkg_reason->reason
3251     if $cust_pkg_reason;
3252 }
3253
3254 =item part_pkg
3255
3256 Returns the definition for this billing item, as an FS::part_pkg object (see
3257 L<FS::part_pkg>).
3258
3259 =cut
3260
3261 sub part_pkg {
3262   my $self = shift;
3263   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
3264   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
3265   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
3266 }
3267
3268 =item old_cust_pkg
3269
3270 Returns the cancelled package this package was changed from, if any.
3271
3272 =cut
3273
3274 sub old_cust_pkg {
3275   my $self = shift;
3276   return '' unless $self->change_pkgnum;
3277   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
3278 }
3279
3280 =item change_cust_main
3281
3282 Returns the customter this package was detached to, if any.
3283
3284 =cut
3285
3286 sub change_cust_main {
3287   my $self = shift;
3288   return '' unless $self->change_custnum;
3289   qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
3290 }
3291
3292 =item calc_setup
3293
3294 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
3295 item.
3296
3297 =cut
3298
3299 sub calc_setup {
3300   my $self = shift;
3301   $self->part_pkg->calc_setup($self, @_);
3302 }
3303
3304 =item calc_recur
3305
3306 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
3307 item.
3308
3309 =cut
3310
3311 sub calc_recur {
3312   my $self = shift;
3313   $self->part_pkg->calc_recur($self, @_);
3314 }
3315
3316 =item base_setup
3317
3318 Calls the I<base_setup> of the FS::part_pkg object associated with this billing
3319 item.
3320
3321 =cut
3322
3323 sub base_setup {
3324   my $self = shift;
3325   $self->part_pkg->base_setup($self, @_);
3326 }
3327
3328 =item base_recur
3329
3330 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
3331 item.
3332
3333 =cut
3334
3335 sub base_recur {
3336   my $self = shift;
3337   $self->part_pkg->base_recur($self, @_);
3338 }
3339
3340 =item calc_remain
3341
3342 Calls the I<calc_remain> of the FS::part_pkg object associated with this
3343 billing item.
3344
3345 =cut
3346
3347 sub calc_remain {
3348   my $self = shift;
3349   $self->part_pkg->calc_remain($self, @_);
3350 }
3351
3352 =item calc_cancel
3353
3354 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
3355 billing item.
3356
3357 =cut
3358
3359 sub calc_cancel {
3360   my $self = shift;
3361   $self->part_pkg->calc_cancel($self, @_);
3362 }
3363
3364 =item cust_bill_pkg
3365
3366 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
3367
3368 =cut
3369
3370 sub cust_bill_pkg {
3371   my $self = shift;
3372   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
3373 }
3374
3375 =item cust_pkg_detail [ DETAILTYPE ]
3376
3377 Returns any customer package details for this package (see
3378 L<FS::cust_pkg_detail>).
3379
3380 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
3381
3382 =cut
3383
3384 sub cust_pkg_detail {
3385   my $self = shift;
3386   my %hash = ( 'pkgnum' => $self->pkgnum );
3387   $hash{detailtype} = shift if @_;
3388   qsearch({
3389     'table'    => 'cust_pkg_detail',
3390     'hashref'  => \%hash,
3391     'order_by' => 'ORDER BY weight, pkgdetailnum',
3392   });
3393 }
3394
3395 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
3396
3397 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
3398
3399 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
3400
3401 If there is an error, returns the error, otherwise returns false.
3402
3403 =cut
3404
3405 sub set_cust_pkg_detail {
3406   my( $self, $detailtype, @details ) = @_;
3407
3408   my $oldAutoCommit = $FS::UID::AutoCommit;
3409   local $FS::UID::AutoCommit = 0;
3410   my $dbh = dbh;
3411
3412   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
3413     my $error = $current->delete;
3414     if ( $error ) {
3415       $dbh->rollback if $oldAutoCommit;
3416       return "error removing old detail: $error";
3417     }
3418   }
3419
3420   foreach my $detail ( @details ) {
3421     my $cust_pkg_detail = new FS::cust_pkg_detail {
3422       'pkgnum'     => $self->pkgnum,
3423       'detailtype' => $detailtype,
3424       'detail'     => $detail,
3425     };
3426     my $error = $cust_pkg_detail->insert;
3427     if ( $error ) {
3428       $dbh->rollback if $oldAutoCommit;
3429       return "error adding new detail: $error";
3430     }
3431
3432   }
3433
3434   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3435   '';
3436
3437 }
3438
3439 =item cust_event
3440
3441 Returns the customer billing events (see L<FS::cust_event>) for this invoice.
3442
3443 =cut
3444
3445 #false laziness w/cust_bill.pm
3446 sub cust_event {
3447   my $self = shift;
3448   qsearch({
3449     'table'     => 'cust_event',
3450     'addl_from' => 'JOIN part_event USING ( eventpart )',
3451     'hashref'   => { 'tablenum' => $self->pkgnum },
3452     'extra_sql' => " AND eventtable = 'cust_pkg' ",
3453   });
3454 }
3455
3456 =item num_cust_event
3457
3458 Returns the number of customer billing events (see L<FS::cust_event>) for this package.
3459
3460 =cut
3461
3462 #false laziness w/cust_bill.pm
3463 sub num_cust_event {
3464   my $self = shift;
3465   my $sql = "SELECT COUNT(*) ". $self->_from_cust_event_where;
3466   $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref->[0];
3467 }
3468
3469 =item exists_cust_event
3470
3471 Returns true if there are customer billing events (see L<FS::cust_event>) for this package.  More efficient than using num_cust_event.
3472
3473 =cut
3474
3475 sub exists_cust_event {
3476   my $self = shift;
3477   my $sql = "SELECT 1 ". $self->_from_cust_event_where. " LIMIT 1";
3478   my $row = $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref;
3479   $row ? $row->[0] : '';
3480 }
3481
3482 sub _from_cust_event_where {
3483   #my $self = shift;
3484   " FROM cust_event JOIN part_event USING ( eventpart ) ".
3485   "  WHERE tablenum = ? AND eventtable = 'cust_pkg' ";
3486 }
3487
3488 sub _prep_ex {
3489   my( $self, $sql, @args ) = @_;
3490   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
3491   $sth->execute(@args)         or die $sth->errstr. " executing $sql";
3492   $sth;
3493 }
3494
3495 =item part_pkg_currency_option OPTIONNAME
3496
3497 Returns a two item list consisting of the currency of this customer, if any,
3498 and a value for the provided option.  If the customer has a currency, the value
3499 is the option value the given name and the currency (see
3500 L<FS::part_pkg_currency>).  Otherwise, if the customer has no currency, is the
3501 regular option value for the given name (see L<FS::part_pkg_option>).
3502
3503 =cut
3504
3505 sub part_pkg_currency_option {
3506   my( $self, $optionname ) = @_;
3507   my $part_pkg = $self->part_pkg;
3508   if ( my $currency = $self->cust_main->currency ) {
3509     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
3510   } else {
3511     ('', $part_pkg->option($optionname) );
3512   }
3513 }
3514
3515 =item cust_svc [ SVCPART ] (old, deprecated usage)
3516
3517 =item cust_svc [ OPTION => VALUE ... ] (current usage)
3518
3519 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
3520
3521 Returns the services for this package, as FS::cust_svc objects (see
3522 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
3523 spcififed, returns only the matching services.
3524
3525 As an optimization, use the cust_svc_unsorted version if you are not displaying
3526 the results.
3527
3528 =cut
3529
3530 sub cust_svc {
3531   my $self = shift;
3532   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
3533   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) );
3534 }
3535
3536 sub cust_svc_unsorted {
3537   my $self = shift;
3538   @{ $self->cust_svc_unsorted_arrayref(@_) };
3539 }
3540
3541 sub cust_svc_unsorted_arrayref {
3542   my $self = shift;
3543
3544   return [] unless $self->num_cust_svc(@_);
3545
3546   my %opt = ();
3547   if ( @_ && $_[0] =~ /^\d+/ ) {
3548     $opt{svcpart} = shift;
3549   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3550     %opt = %{ $_[0] };
3551   } elsif ( @_ ) {
3552     %opt = @_;
3553   }
3554
3555   my %search = (
3556     'select'    => 'cust_svc.*, part_svc.*',
3557     'table'     => 'cust_svc',
3558     'hashref'   => { 'pkgnum' => $self->pkgnum },
3559     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )',
3560   );
3561   $search{hashref}->{svcpart} = $opt{svcpart}
3562     if $opt{svcpart};
3563   $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{svcdb} )
3564     if $opt{svcdb};
3565
3566   [ qsearch(\%search) ];
3567
3568 }
3569
3570 =item overlimit [ SVCPART ]
3571
3572 Returns the services for this package which have exceeded their
3573 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
3574 is specified, return only the matching services.
3575
3576 =cut
3577
3578 sub overlimit {
3579   my $self = shift;
3580   return () unless $self->num_cust_svc(@_);
3581   grep { $_->overlimit } $self->cust_svc(@_);
3582 }
3583
3584 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3585
3586 Returns historical services for this package created before END TIMESTAMP and
3587 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
3588 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
3589 I<pkg_svc.hidden> flag will be omitted.
3590
3591 =cut
3592
3593 sub h_cust_svc {
3594   my $self = shift;
3595   warn "$me _h_cust_svc called on $self\n"
3596     if $DEBUG;
3597
3598   my ($end, $start, $mode) = @_;
3599
3600   local($FS::Record::qsearch_qualify_columns) = 0;
3601
3602   my @cust_svc = $self->_sort_cust_svc(
3603     [ qsearch( 'h_cust_svc',
3604       { 'pkgnum' => $self->pkgnum, },  
3605       FS::h_cust_svc->sql_h_search(@_),  
3606     ) ]
3607   );
3608
3609   if ( defined($mode) && $mode eq 'I' ) {
3610     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
3611     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
3612   } else {
3613     return @cust_svc;
3614   }
3615 }
3616
3617 sub _sort_cust_svc {
3618   my( $self, $arrayref ) = @_;
3619
3620   my $sort =
3621     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
3622
3623   my %pkg_svc = map { $_->svcpart => $_ }
3624                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
3625
3626   map  { $_->[0] }
3627   sort $sort
3628   map {
3629         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
3630         [ $_,
3631           $pkg_svc ? $pkg_svc->primary_svc : '',
3632           $pkg_svc ? $pkg_svc->quantity : 0,
3633         ];
3634       }
3635   @$arrayref;
3636
3637 }
3638
3639 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
3640
3641 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
3642
3643 Returns the number of services for this package.  Available options are svcpart
3644 and svcdb.  If either is spcififed, returns only the matching services.
3645
3646 =cut
3647
3648 sub num_cust_svc {
3649   my $self = shift;
3650
3651   return $self->{'_num_cust_svc'}
3652     if !scalar(@_)
3653        && exists($self->{'_num_cust_svc'})
3654        && $self->{'_num_cust_svc'} =~ /\d/;
3655
3656   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
3657     if $DEBUG > 2;
3658
3659   my %opt = ();
3660   if ( @_ && $_[0] =~ /^\d+/ ) {
3661     $opt{svcpart} = shift;
3662   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3663     %opt = %{ $_[0] };
3664   } elsif ( @_ ) {
3665     %opt = @_;
3666   }
3667
3668   my $select = 'SELECT COUNT(*) FROM cust_svc ';
3669   my $where = ' WHERE pkgnum = ? ';
3670   my @param = ($self->pkgnum);
3671
3672   if ( $opt{'svcpart'} ) {
3673     $where .= ' AND svcpart = ? ';
3674     push @param, $opt{'svcpart'};
3675   }
3676   if ( $opt{'svcdb'} ) {
3677     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
3678     $where .= ' AND svcdb = ? ';
3679     push @param, $opt{'svcdb'};
3680   }
3681
3682   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
3683   $sth->execute(@param) or die $sth->errstr;
3684   $sth->fetchrow_arrayref->[0];
3685 }
3686
3687 =item available_part_svc 
3688
3689 Returns a list of FS::part_svc objects representing services included in this
3690 package but not yet provisioned.  Each FS::part_svc object also has an extra
3691 field, I<num_avail>, which specifies the number of available services.
3692
3693 Accepts option I<provision_hold>;  if true, only returns part_svc for which the
3694 associated pkg_svc has the provision_hold flag set.
3695
3696 =cut
3697
3698 sub available_part_svc {
3699   my $self = shift;
3700   my %opt  = @_;
3701
3702   my $pkg_quantity = $self->quantity || 1;
3703
3704   grep { $_->num_avail > 0 }
3705   map {
3706     my $part_svc = $_->part_svc;
3707     $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
3708     $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
3709
3710     # more evil encapsulation breakage
3711     if ($part_svc->{'Hash'}{'num_avail'} > 0) {
3712       my @exports = $part_svc->part_export_did;
3713       $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
3714         }
3715
3716     $part_svc;
3717   }
3718   grep { $opt{'provision_hold'} ? $_->provision_hold : 1 }
3719   $self->part_pkg->pkg_svc;
3720 }
3721
3722 =item part_svc [ OPTION => VALUE ... ]
3723
3724 Returns a list of FS::part_svc objects representing provisioned and available
3725 services included in this package.  Each FS::part_svc object also has the
3726 following extra fields:
3727
3728 =over 4
3729
3730 =item num_cust_svc
3731
3732 (count)
3733
3734 =item num_avail
3735
3736 (quantity - count)
3737
3738 =item cust_pkg_svc
3739
3740 (services) - array reference containing the provisioned services, as cust_svc objects
3741
3742 =back
3743
3744 Accepts two options:
3745
3746 =over 4
3747
3748 =item summarize_size
3749
3750 If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc
3751 is this size or greater.
3752
3753 =item hide_discontinued
3754
3755 If true, will omit looking for services that are no longer avaialble in the
3756 package definition.
3757
3758 =back
3759
3760 =cut
3761
3762 #svcnum
3763 #label -> ($cust_svc->label)[1]
3764
3765 sub part_svc {
3766   my $self = shift;
3767   my %opt = @_;
3768
3769   my $pkg_quantity = $self->quantity || 1;
3770
3771   #XXX some sort of sort order besides numeric by svcpart...
3772   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
3773     my $pkg_svc = $_;
3774     my $part_svc = $pkg_svc->part_svc;
3775     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3776     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
3777     $part_svc->{'Hash'}{'num_avail'}    =
3778       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );