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