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