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