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