cd801d9d4f0ca1c2b0ce6c4008b8a5e961f3ccac
[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
537   # cust_svc is handled by canceling the package before deleting it
538   # cust_pkg_option is handled via option_Common
539
540   my $oldAutoCommit = $FS::UID::AutoCommit;
541   local $FS::UID::AutoCommit = 0;
542   my $dbh = dbh;
543
544   foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
545     my $error = $cust_pkg_discount->delete;
546     if ( $error ) {
547       $dbh->rollback if $oldAutoCommit;
548       return $error;
549     }
550   }
551   #cust_bill_pkg_discount?
552
553   foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
554     my $error = $cust_pkg_detail->delete;
555     if ( $error ) {
556       $dbh->rollback if $oldAutoCommit;
557       return $error;
558     }
559   }
560
561   foreach my $cust_pkg_reason (
562     qsearchs( {
563                 'table' => 'cust_pkg_reason',
564                 'hashref' => { 'pkgnum' => $self->pkgnum },
565               }
566             )
567   ) {
568     my $error = $cust_pkg_reason->delete;
569     if ( $error ) {
570       $dbh->rollback if $oldAutoCommit;
571       return $error;
572     }
573   }
574
575   foreach my $pkg_referral ( $self->pkg_referral ) {
576     my $error = $pkg_referral->delete;
577     if ( $error ) {
578       $dbh->rollback if $oldAutoCommit;
579       return $error;
580     }
581   }
582
583   my $error = $self->SUPER::delete(@_);
584   if ( $error ) {
585     $dbh->rollback if $oldAutoCommit;
586     return $error;
587   }
588
589   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
590
591   '';
592
593 }
594
595 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
596
597 Replaces the OLD_RECORD with this one in the database.  If there is an error,
598 returns the error, otherwise returns false.
599
600 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
601
602 Changing pkgpart may have disasterous effects.  See the order subroutine.
603
604 setup and bill are normally updated by calling the bill method of a customer
605 object (see L<FS::cust_main>).
606
607 suspend is normally updated by the suspend and unsuspend methods.
608
609 cancel is normally updated by the cancel method (and also the order subroutine
610 in some cases).
611
612 Available options are:
613
614 =over 4
615
616 =item reason
617
618 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.
619
620 =item reason_otaker
621
622 the access_user (see L<FS::access_user>) providing the reason
623
624 =item options
625
626 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
627
628 =back
629
630 =cut
631
632 sub replace {
633   my $new = shift;
634
635   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
636               ? shift
637               : $new->replace_old;
638
639   my $options = 
640     ( ref($_[0]) eq 'HASH' )
641       ? shift
642       : { @_ };
643
644   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
645   #return "Can't change otaker!" if $old->otaker ne $new->otaker;
646
647   #allow this *sigh*
648   #return "Can't change setup once it exists!"
649   #  if $old->getfield('setup') &&
650   #     $old->getfield('setup') != $new->getfield('setup');
651
652   #some logic for bill, susp, cancel?
653
654   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
655
656   my $oldAutoCommit = $FS::UID::AutoCommit;
657   local $FS::UID::AutoCommit = 0;
658   my $dbh = dbh;
659
660   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
661     if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
662       my $error = $new->insert_reason(
663         'reason'        => $options->{'reason'},
664         'date'          => $new->$method,
665         'action'        => $method,
666         'reason_otaker' => $options->{'reason_otaker'},
667       );
668       if ( $error ) {
669         dbh->rollback if $oldAutoCommit;
670         return "Error inserting cust_pkg_reason: $error";
671       }
672     }
673   }
674
675   #save off and freeze RADIUS attributes for any associated svc_acct records
676   my @svc_acct = ();
677   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
678
679                 #also check for specific exports?
680                 # to avoid spurious modify export events
681     @svc_acct = map  { $_->svc_x }
682                 grep { $_->part_svc->svcdb eq 'svc_acct' }
683                      $old->cust_svc;
684
685     $_->snapshot foreach @svc_acct;
686
687   }
688
689   my $error =  $new->export_pkg_change($old)
690             || $new->SUPER::replace( $old,
691                                      $options->{options}
692                                        ? $options->{options}
693                                        : ()
694                                    );
695   if ( $error ) {
696     $dbh->rollback if $oldAutoCommit;
697     return $error;
698   }
699
700   #for prepaid packages,
701   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
702   foreach my $old_svc_acct ( @svc_acct ) {
703     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
704     my $s_error =
705       $new_svc_acct->replace( $old_svc_acct,
706                               'depend_jobnum' => $options->{depend_jobnum},
707                             );
708     if ( $s_error ) {
709       $dbh->rollback if $oldAutoCommit;
710       return $s_error;
711     }
712   }
713
714   # also run exports if removing locationnum?
715   #   doesn't seem to happen, and we don't export blank locationnum on insert...
716   if ($new->locationnum and ($new->locationnum != $old->locationnum)) {
717     my $conf = new FS::Conf;
718     my @part_export =
719       map qsearch( 'part_export', {exportnum=>$_} ),
720         $conf->config('cust_location-exports'); #, $agentnum
721
722     foreach my $part_export ( @part_export ) {
723       my $error = $part_export->export_pkg_location($new); #, @$export_args);
724       if ( $error ) {
725         $dbh->rollback if $oldAutoCommit;
726         return "exporting to ". $part_export->exporttype.
727                " (transaction rolled back): $error";
728       }
729     }
730   }
731
732   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
733   '';
734
735 }
736
737 =item check
738
739 Checks all fields to make sure this is a valid billing item.  If there is an
740 error, returns the error, otherwise returns false.  Called by the insert and
741 replace methods.
742
743 =cut
744
745 sub check {
746   my $self = shift;
747
748   if ( !$self->locationnum or $self->locationnum == -1 ) {
749     $self->set('locationnum', $self->cust_main->ship_locationnum);
750   }
751
752   my $error = 
753     $self->ut_numbern('pkgnum')
754     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
755     || $self->ut_numbern('pkgpart')
756     || $self->ut_foreign_keyn('contactnum',  'contact',       'contactnum' )
757     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
758     || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum')
759     || $self->ut_numbern('quantity')
760     || $self->ut_numbern('start_date')
761     || $self->ut_numbern('setup')
762     || $self->ut_numbern('bill')
763     || $self->ut_numbern('susp')
764     || $self->ut_numbern('cancel')
765     || $self->ut_numbern('adjourn')
766     || $self->ut_numbern('resume')
767     || $self->ut_numbern('expire')
768     || $self->ut_numbern('dundate')
769     || $self->ut_flag('no_auto', [ '', 'Y' ])
770     || $self->ut_flag('waive_setup', [ '', 'Y' ])
771     || $self->ut_flag('separate_bill')
772     || $self->ut_textn('agent_pkgid')
773     || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
774     || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
775     || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum')
776     || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum')
777     || $self->ut_foreign_keyn('change_to_pkgnum', 'cust_pkg', 'pkgnum')
778   ;
779   return $error if $error;
780
781   return "A package with both start date (future start) and setup date (already started) will never bill"
782     if $self->start_date && $self->setup && ! $upgrade;
783
784   return "A future unsuspend date can only be set for a package with a suspend date"
785     if $self->resume and !$self->susp and !$self->adjourn;
786
787   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
788
789   if ( $self->dbdef_table->column('manual_flag') ) {
790     $self->manual_flag('') if $self->manual_flag eq ' ';
791     $self->manual_flag =~ /^([01]?)$/
792       or return "Illegal manual_flag ". $self->manual_flag;
793     $self->manual_flag($1);
794   }
795
796   $self->SUPER::check;
797 }
798
799 =item check_pkgpart
800
801 Check the pkgpart to make sure it's allowed with the reg_code and/or
802 promo_code of the package (if present) and with the customer's agent.
803 Called from C<insert>, unless we are doing a package change that doesn't
804 affect pkgpart.
805
806 =cut
807
808 sub check_pkgpart {
809   my $self = shift;
810
811   # my $error = $self->ut_numbern('pkgpart'); # already done
812
813   my $error;
814   if ( $self->reg_code ) {
815
816     unless ( grep { $self->pkgpart == $_->pkgpart }
817              map  { $_->reg_code_pkg }
818              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
819                                      'agentnum' => $self->cust_main->agentnum })
820            ) {
821       return "Unknown registration code";
822     }
823
824   } elsif ( $self->promo_code ) {
825
826     my $promo_part_pkg =
827       qsearchs('part_pkg', {
828         'pkgpart'    => $self->pkgpart,
829         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
830       } );
831     return 'Unknown promotional code' unless $promo_part_pkg;
832
833   } else { 
834
835     unless ( $disable_agentcheck ) {
836       my $agent =
837         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
838       return "agent ". $agent->agentnum. ':'. $agent->agent.
839              " can't purchase pkgpart ". $self->pkgpart
840         unless $agent->pkgpart_hashref->{ $self->pkgpart }
841             || $agent->agentnum == $self->part_pkg->agentnum;
842     }
843
844     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
845     return $error if $error;
846
847   }
848
849   '';
850
851 }
852
853 =item cancel [ OPTION => VALUE ... ]
854
855 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
856 in this package, then cancels the package itself (sets the cancel field to
857 now).
858
859 Available options are:
860
861 =over 4
862
863 =item quiet - can be set true to supress email cancellation notices.
864
865 =item time -  can be set to cancel the package based on a specific future or 
866 historical date.  Using time ensures that the remaining amount is calculated 
867 correctly.  Note however that this is an immediate cancel and just changes 
868 the date.  You are PROBABLY looking to expire the account instead of using 
869 this.
870
871 =item reason - can be set to a cancellation reason (see L<FS:reason>), 
872 either a reasonnum of an existing reason, or passing a hashref will create 
873 a new reason.  The hashref should have the following keys: typenum - Reason 
874 type (see L<FS::reason_type>, reason - Text of the new reason.
875
876 =item date - can be set to a unix style timestamp to specify when to 
877 cancel (expire)
878
879 =item nobill - can be set true to skip billing if it might otherwise be done.
880
881 =item unused_credit - can be set to 1 to credit the remaining time, or 0 to 
882 not credit it.  This must be set (by change()) when changing the package 
883 to a different pkgpart or location, and probably shouldn't be in any other 
884 case.  If it's not set, the 'unused_credit_cancel' part_pkg option will 
885 be used.
886
887 =item no_delay_cancel - prevents delay_cancel behavior
888 no matter what other options say, for use when changing packages (or any
889 other time you're really sure you want an immediate cancel)
890
891 =back
892
893 If there is an error, returns the error, otherwise returns false.
894
895 =cut
896
897 #NOT DOCUMENTING - this should only be used when calling recursively
898 #=item delay_cancel - for internal use, to allow proper handling of
899 #supplemental packages when the main package is flagged to suspend 
900 #before cancelling, probably shouldn't be used otherwise (set the
901 #corresponding package option instead)
902
903 sub cancel {
904   my( $self, %options ) = @_;
905   my $error;
906
907   # supplemental packages can now be separately canceled, though the UI
908   # shouldn't permit it
909   #
910   ## pass all suspend/cancel actions to the main package
911   ## (unless the pkglinknum has been removed, then the link is defunct and
912   ## this package can be canceled on its own)
913   #if ( $self->main_pkgnum and $self->pkglinknum and !$options{'from_main'} ) {
914   #  return $self->main_pkg->cancel(%options);
915   #}
916
917   my $conf = new FS::Conf;
918
919   warn "cust_pkg::cancel called with options".
920        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
921     if $DEBUG;
922
923   my $oldAutoCommit = $FS::UID::AutoCommit;
924   local $FS::UID::AutoCommit = 0;
925   my $dbh = dbh;
926   
927   my $old = $self->select_for_update;
928
929   if ( $old->get('cancel') || $self->get('cancel') ) {
930     dbh->rollback if $oldAutoCommit;
931     return "";  # no error
932   }
933
934   # XXX possibly set cancel_time to the expire date?
935   my $cancel_time = $options{'time'} || time;
936   my $date = $options{'date'} if $options{'date'}; # expire/cancel later
937   $date = '' if ($date && $date <= $cancel_time);      # complain instead?
938
939   my $delay_cancel = $options{'no_delay_cancel'} ? 0 : $options{'delay_cancel'};
940   if ( !$date && $self->part_pkg->option('delay_cancel',1)
941        && (($self->status eq 'active') || ($self->status eq 'suspended'))
942        && !$options{'no_delay_cancel'}
943   ) {
944     my $expdays = $conf->config('part_pkg-delay_cancel-days') || 1;
945     my $expsecs = 60*60*24*$expdays;
946     my $suspfor = $self->susp ? $cancel_time - $self->susp : 0;
947     $expsecs = $expsecs - $suspfor if $suspfor;
948     unless ($expsecs <= 0) { #if it's already been suspended long enough, don't re-suspend
949       $delay_cancel = 1;
950       $date = $cancel_time + $expsecs;
951     }
952   }
953
954   #race condition: usage could be ongoing until unprovisioned
955   #resolved by performing a change package instead (which unprovisions) and
956   #later cancelling
957   if ( !$options{nobill} && !$date ) {
958     # && $conf->exists('bill_usage_on_cancel') ) { #calc_cancel checks this
959       my $copy = $self->new({$self->hash});
960       my $error =
961         $copy->cust_main->bill( 'pkg_list' => [ $copy ], 
962                                 'cancel'   => 1,
963                                 'time'     => $cancel_time );
964       warn "Error billing during cancel, custnum ".
965         #$self->cust_main->custnum. ": $error"
966         ": $error"
967         if $error;
968   }
969
970   if ( $options{'reason'} ) {
971     $error = $self->insert_reason( 'reason' => $options{'reason'},
972                                    'action' => $date ? 'expire' : 'cancel',
973                                    'date'   => $date ? $date : $cancel_time,
974                                    'reason_otaker' => $options{'reason_otaker'},
975                                  );
976     if ( $error ) {
977       dbh->rollback if $oldAutoCommit;
978       return "Error inserting cust_pkg_reason: $error";
979     }
980   }
981
982   my %svc_cancel_opt = ();
983   $svc_cancel_opt{'date'} = $date if $date;
984   foreach my $cust_svc (
985     #schwartz
986     map  { $_->[0] }
987     sort { $a->[1] <=> $b->[1] }
988     map  { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; }
989     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
990   ) {
991     my $part_svc = $cust_svc->part_svc;
992     next if ( defined($part_svc) and $part_svc->preserve );
993     my $error = $cust_svc->cancel( %svc_cancel_opt );
994
995     if ( $error ) {
996       $dbh->rollback if $oldAutoCommit;
997       return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
998              " cust_svc: $error";
999     }
1000   }
1001
1002   # if a reasonnum was passed, get the actual reason object so we can check
1003   # unused_credit
1004
1005   my $reason;
1006   if ($options{'reason'} =~ /^\d+$/) {
1007     $reason = FS::reason->by_key($options{'reason'});
1008   }
1009
1010   unless ($date) {
1011     # credit remaining time if any of these are true:
1012     # - unused_credit => 1 was passed (this happens when canceling a package
1013     #   for a package change when unused_credit_change is set)
1014     # - no unused_credit option, and there is a cancel reason, and the cancel
1015     #   reason says to credit the package
1016     # - no unused_credit option, and the package definition says to credit the
1017     #   package on cancellation
1018     my $do_credit;
1019     if ( exists($options{'unused_credit'}) ) {
1020       $do_credit = $options{'unused_credit'};
1021     } elsif ( defined($reason) && $reason->unused_credit ) {
1022       $do_credit = 1;
1023     } else {
1024       $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
1025     }
1026     if ( $do_credit ) {
1027       my $error = $self->credit_remaining('cancel', $cancel_time);
1028       if ($error) {
1029         $dbh->rollback if $oldAutoCommit;
1030         return $error;
1031       }
1032     }
1033   } #unless $date
1034
1035   my %hash = $self->hash;
1036   if ( $date ) {
1037     $hash{'expire'} = $date;
1038     if ($delay_cancel) {
1039       # just to be sure these are clear
1040       $hash{'adjourn'} = undef;
1041       $hash{'resume'} = undef;
1042     }
1043   } else {
1044     $hash{'cancel'} = $cancel_time;
1045   }
1046   $hash{'change_custnum'} = $options{'change_custnum'};
1047
1048   # if this is a supplemental package that's lost its part_pkg_link, and it's
1049   # being canceled for real, unlink it completely
1050   if ( !$date and ! $self->pkglinknum ) {
1051     $hash{main_pkgnum} = '';
1052   }
1053
1054   # if there is a future package change scheduled, unlink from it (like
1055   # abort_change) first, then delete it.
1056   $hash{'change_to_pkgnum'} = '';
1057
1058   # save the package state
1059   my $new = new FS::cust_pkg ( \%hash );
1060   $error = $new->replace( $self, options => { $self->options } );
1061
1062   if ( $self->change_to_pkgnum ) {
1063     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
1064     $error ||= $change_to->cancel('no_delay_cancel' => 1) || $change_to->delete;
1065   }
1066   if ( $error ) {
1067     $dbh->rollback if $oldAutoCommit;
1068     return $error;
1069   }
1070
1071   foreach my $supp_pkg ( $self->supplemental_pkgs ) {
1072     $error = $supp_pkg->cancel(%options, 
1073       'from_main' => 1, 
1074       'date' => $date, #in case it got changed by delay_cancel
1075       'delay_cancel' => $delay_cancel,
1076     );
1077     if ( $error ) {
1078       $dbh->rollback if $oldAutoCommit;
1079       return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
1080     }
1081   }
1082
1083   if ($delay_cancel && !$options{'from_main'}) {
1084     $error = $new->suspend(
1085       'from_cancel' => 1,
1086       'time'        => $cancel_time
1087     );
1088   }
1089
1090   unless ($date) {
1091     foreach my $usage ( $self->cust_pkg_usage ) {
1092       $error = $usage->delete;
1093       if ( $error ) {
1094         $dbh->rollback if $oldAutoCommit;
1095         return "deleting usage pools: $error";
1096       }
1097     }
1098   }
1099
1100   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1101   return '' if $date; #no errors
1102
1103   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
1104   if ( !$options{'quiet'} && 
1105         $conf->exists('emailcancel', $self->cust_main->agentnum) && 
1106         @invoicing_list ) {
1107     my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
1108     my $error = '';
1109     if ( $msgnum ) {
1110       my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
1111       $error = $msg_template->send( 'cust_main' => $self->cust_main,
1112                                     'object'    => $self );
1113     }
1114     #should this do something on errors?
1115   }
1116
1117   ''; #no errors
1118
1119 }
1120
1121 =item cancel_if_expired [ NOW_TIMESTAMP ]
1122
1123 Cancels this package if its expire date has been reached.
1124
1125 =cut
1126
1127 sub cancel_if_expired {
1128   my $self = shift;
1129   my $time = shift || time;
1130   return '' unless $self->expire && $self->expire <= $time;
1131   my $error = $self->cancel;
1132   if ( $error ) {
1133     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
1134            $self->custnum. ": $error";
1135   }
1136   '';
1137 }
1138
1139 =item uncancel_svc_x
1140
1141 For cancelled cust_pkg, returns a list of new, uninserted FS::svc_X records 
1142 for services that would be inserted by L</uncancel>.  Returned objects also
1143 include the field _h_svc_x, which contains the service history object.
1144
1145 Set pkgnum before inserting.
1146
1147 Accepts the following options:
1148
1149 only_svcnum - arrayref of svcnum, only returns objects for these svcnum 
1150 (and only if they would otherwise be returned by this)
1151
1152 =cut
1153
1154 sub uncancel_svc_x {
1155   my ($self, %opt) = @_;
1156
1157   die 'uncancel_svc_x called on a non-cancelled cust_pkg' unless $self->get('cancel');
1158
1159   #find historical services within this timeframe before the package cancel
1160   # (incompatible with "time" option to cust_pkg->cancel?)
1161   my $fuzz = 2 * 60; #2 minutes?  too much?   (might catch separate unprovision)
1162                      #            too little? (unprovisioing export delay?)
1163   my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
1164   my @h_cust_svc = $self->h_cust_svc( $end, $start );
1165
1166   my @svc_x;
1167   foreach my $h_cust_svc (@h_cust_svc) {
1168     next if $opt{'only_svcnum'} && !(grep { $_ == $h_cust_svc->svcnum } @{$opt{'only_svcnum'}});
1169     # filter out services that still exist on this package (ie preserved svcs)
1170     # but keep services that have since been provisioned on another package (for informational purposes)
1171     next if qsearchs('cust_svc',{ 'svcnum' => $h_cust_svc->svcnum, 'pkgnum' => $self->pkgnum });
1172     my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
1173     next unless $h_svc_x; # this probably doesn't happen, but just in case
1174     (my $table = $h_svc_x->table) =~ s/^h_//;
1175     require "FS/$table.pm";
1176     my $class = "FS::$table";
1177     my $svc_x = $class->new( {
1178       'svcpart' => $h_cust_svc->svcpart,
1179       '_h_svc_x' => $h_svc_x,
1180       map { $_ => $h_svc_x->get($_) } fields($table)
1181     } );
1182
1183     # radius_usergroup
1184     if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
1185       $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
1186     }
1187
1188     #these are pretty rare, but should handle them
1189     # - dsl_device (mac addresses)
1190     # - phone_device (mac addresses)
1191     # - dsl_note (ikano notes)
1192     # - domain_record (i.e. restore DNS information w/domains)
1193     # - inventory_item(?) (inventory w/un-cancelling service?)
1194     # - nas (svc_broaband nas stuff)
1195     #this stuff is unused in the wild afaik
1196     # - mailinglistmember
1197     # - router.svcnum?
1198     # - svc_domain.parent_svcnum?
1199     # - acct_snarf (ancient mail fetching config)
1200     # - cgp_rule (communigate)
1201     # - cust_svc_option (used by our Tron stuff)
1202     # - acct_rt_transaction (used by our time worked stuff)
1203
1204     push @svc_x, $svc_x;
1205   }
1206   return @svc_x;
1207 }
1208
1209 =item uncancel_svc_summary
1210
1211 Returns an array of hashrefs, one for each service that could 
1212 potentially be reprovisioned by L</uncancel>, with the following keys:
1213
1214 svcpart
1215
1216 svc
1217
1218 uncancel_svcnum
1219
1220 label - from history table if not currently calculable, undefined if it can't be loaded
1221
1222 reprovisionable - 1 if test reprovision succeeded, otherwise 0
1223
1224 num_cust_svc - number of svcs for this svcpart, only if summarizing (see below)
1225
1226 Cannot be run from within a transaction.  Performs inserts
1227 to test the results, and then rolls back the transaction.
1228 Does not perform exports, so does not catch if export would fail.
1229
1230 Also accepts the following options:
1231
1232 no_test_reprovision - skip the test inserts (reprovisionable field will not exist)
1233
1234 summarize_size - if true, returns a single summary record for svcparts with at
1235 least this many svcs, will have key num_cust_svc but not uncancel_svcnum, label or reprovisionable
1236
1237 =cut
1238
1239 sub uncancel_svc_summary {
1240   my ($self, %opt) = @_;
1241
1242   die 'uncancel_svc_summary called on a non-cancelled cust_pkg' unless $self->get('cancel');
1243   die 'uncancel_svc_summary called from within a transaction' unless $FS::UID::AutoCommit;
1244
1245   local $FS::svc_Common::noexport_hack = 1; # very important not to run exports!!!
1246   local $FS::UID::AutoCommit = 0;
1247
1248   # sort by svcpart, to check summarize_size
1249   my $uncancel_svc_x = {};
1250   foreach my $svc_x (sort { $a->{'svcpart'} <=> $b->{'svcpart'} } $self->uncancel_svc_x) {
1251     $uncancel_svc_x->{$svc_x->svcpart} = [] unless $uncancel_svc_x->{$svc_x->svcpart};
1252     push @{$uncancel_svc_x->{$svc_x->svcpart}}, $svc_x;
1253   }
1254
1255   my @out;
1256   foreach my $svcpart (keys %$uncancel_svc_x) {
1257     my @svcpart_svc_x = @{$uncancel_svc_x->{$svcpart}};
1258     if ($opt{'summarize_size'} && (@svcpart_svc_x >= $opt{'summarize_size'})) {
1259       my $svc_x = $svcpart_svc_x[0]; #grab first one for access to $part_svc
1260       my $part_svc = $svc_x->part_svc;
1261       push @out, {
1262         'svcpart'      => $part_svc->svcpart,
1263         'svc'          => $part_svc->svc,
1264         'num_cust_svc' => scalar(@svcpart_svc_x),
1265       };
1266     } else {
1267       foreach my $svc_x (@svcpart_svc_x) {
1268         my $part_svc = $svc_x->part_svc;
1269         my $out = {
1270           'svcpart' => $part_svc->svcpart,
1271           'svc'     => $part_svc->svc,
1272           'uncancel_svcnum' => $svc_x->get('_h_svc_x')->svcnum,
1273         };
1274         $svc_x->pkgnum($self->pkgnum); # provisioning services on a canceled package, will be rolled back
1275         if ($opt{'no_test_reprovision'} or $svc_x->insert) {
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       manual_flag 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   # Before going any further here: if the package is still in the pre-setup
2282   # state, it's safe to modify it in place. No need to charge/credit for 
2283   # partial period, transfer services, transfer usage pools, copy invoice
2284   # details, or change any dates.
2285   if ( ! $self->setup and ! $opt->{cust_pkg} and ! $opt->{cust_main} ) {
2286     foreach ( qw( locationnum pkgpart quantity refnum salesnum ) ) {
2287       if ( length($opt->{$_}) ) {
2288         $self->set($_, $opt->{$_});
2289       }
2290     }
2291     # almost. if the new pkgpart specifies start/adjourn/expire timers, 
2292     # apply those.
2293     if ( $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart ) {
2294       $error ||= $self->set_initial_timers;
2295     }
2296     # but if contract_end was explicitly specified, that overrides all else
2297     $self->set('contract_end', $opt->{'contract_end'})
2298       if $opt->{'contract_end'};
2299     $error ||= $self->replace;
2300     if ( $error ) {
2301       $dbh->rollback if $oldAutoCommit;
2302       return "modifying package: $error";
2303     } else {
2304       $dbh->commit if $oldAutoCommit;
2305       return $self;
2306     }
2307   }
2308
2309   my %hash = (); 
2310
2311   my $time = time;
2312
2313   $hash{'setup'} = $time if $self->get('setup');
2314
2315   $hash{'change_date'} = $time;
2316   $hash{"change_$_"}  = $self->$_()
2317     foreach qw( pkgnum pkgpart locationnum );
2318
2319   if ( $opt->{'cust_pkg'} ) {
2320     # treat changing to a package with a different pkgpart as a 
2321     # pkgpart change (because it is)
2322     $opt->{'pkgpart'} = $opt->{'cust_pkg'}->pkgpart;
2323   }
2324
2325   # whether to override pkgpart checking on the new package
2326   my $same_pkgpart = 1;
2327   if ( $opt->{'pkgpart'} and ( $opt->{'pkgpart'} != $self->pkgpart ) ) {
2328     $same_pkgpart = 0;
2329   }
2330
2331   my $unused_credit = 0;
2332   my $keep_dates = $opt->{'keep_dates'};
2333
2334   # Special case.  If the pkgpart is changing, and the customer is going to be
2335   # credited for remaining time, don't keep setup, bill, or last_bill dates,
2336   # and DO pass the flag to cancel() to credit the customer.  If the old
2337   # package had a setup date, set the new package's setup to the package
2338   # change date so that it has the same status as before.
2339   if ( $opt->{'pkgpart'} 
2340        and $opt->{'pkgpart'} != $self->pkgpart
2341        and $self->part_pkg->option('unused_credit_change', 1) ) {
2342     $unused_credit = 1;
2343     $keep_dates = 0;
2344     $hash{'last_bill'} = '';
2345     $hash{'bill'} = '';
2346   }
2347
2348   if ( $keep_dates ) {
2349     foreach my $date ( qw(setup bill last_bill) ) {
2350       $hash{$date} = $self->getfield($date);
2351     }
2352   }
2353   # always keep the following dates
2354   foreach my $date (qw(order_date susp adjourn cancel expire resume 
2355                     start_date contract_end)) {
2356     $hash{$date} = $self->getfield($date);
2357   }
2358   # but if contract_end was explicitly specified, that overrides all else
2359   $hash{'contract_end'} = $opt->{'contract_end'}
2360     if $opt->{'contract_end'};
2361
2362   # allow $opt->{'locationnum'} = '' to specifically set it to null
2363   # (i.e. customer default location)
2364   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2365
2366   # usually this doesn't matter.  the two cases where it does are:
2367   # 1. unused_credit_change + pkgpart change + setup fee on the new package
2368   # and
2369   # 2. (more importantly) changing a package before it's billed
2370   $hash{'waive_setup'} = $self->waive_setup;
2371
2372   # if this package is scheduled for a future package change, preserve that
2373   $hash{'change_to_pkgnum'} = $self->change_to_pkgnum;
2374
2375   my $custnum = $self->custnum;
2376   if ( $opt->{cust_main} ) {
2377     my $cust_main = $opt->{cust_main};
2378     unless ( $cust_main->custnum ) { 
2379       my $error = $cust_main->insert( @{ $opt->{cust_main_insert_args}||[] } );
2380       if ( $error ) {
2381         $dbh->rollback if $oldAutoCommit;
2382         return "inserting customer record: $error";
2383       }
2384     }
2385     $custnum = $cust_main->custnum;
2386   }
2387
2388   $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
2389
2390   my $cust_pkg;
2391   if ( $opt->{'cust_pkg'} ) {
2392     # The target package already exists; update it to show that it was 
2393     # changed from this package.
2394     $cust_pkg = $opt->{'cust_pkg'};
2395
2396     # follow all the above rules for date changes, etc.
2397     foreach (keys %hash) {
2398       $cust_pkg->set($_, $hash{$_});
2399     }
2400     # except those that implement the future package change behavior
2401     foreach (qw(change_to_pkgnum start_date expire)) {
2402       $cust_pkg->set($_, '');
2403     }
2404
2405     $error = $cust_pkg->replace;
2406
2407   } else {
2408     # Create the new package.
2409     $cust_pkg = new FS::cust_pkg {
2410       custnum     => $custnum,
2411       locationnum => $opt->{'locationnum'},
2412       ( map {  $_ => ( $opt->{$_} || $self->$_() )  }
2413           qw( pkgpart quantity refnum salesnum )
2414       ),
2415       %hash,
2416     };
2417     $error = $cust_pkg->insert( 'change' => 1,
2418                                 'allow_pkgpart' => $same_pkgpart );
2419   }
2420   if ($error) {
2421     $dbh->rollback if $oldAutoCommit;
2422     return "inserting new package: $error";
2423   }
2424
2425   # Transfer services and cancel old package.
2426   # Enforce service limits only if this is a pkgpart change.
2427   local $FS::cust_svc::ignore_quantity;
2428   $FS::cust_svc::ignore_quantity = 1 if $same_pkgpart;
2429   $error = $self->transfer($cust_pkg);
2430   if ($error and $error == 0) {
2431     # $old_pkg->transfer failed.
2432     $dbh->rollback if $oldAutoCommit;
2433     return "transferring $error";
2434   }
2435
2436   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2437     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2438     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
2439     if ($error and $error == 0) {
2440       # $old_pkg->transfer failed.
2441       $dbh->rollback if $oldAutoCommit;
2442       return "converting $error";
2443     }
2444   }
2445
2446   # We set unprotect_svcs when executing a "future package change".  It's 
2447   # not a user-interactive operation, so returning an error means the 
2448   # package change will just fail.  Rather than have that happen, we'll 
2449   # let leftover services be deleted.
2450   if ($error > 0 and !$opt->{'unprotect_svcs'}) {
2451     # Transfers were successful, but we still had services left on the old
2452     # package.  We can't change the package under this circumstances, so abort.
2453     $dbh->rollback if $oldAutoCommit;
2454     return "unable to transfer all services";
2455   }
2456
2457   #reset usage if changing pkgpart
2458   # AND usage rollover is off (otherwise adds twice, now and at package bill)
2459   if ($self->pkgpart != $cust_pkg->pkgpart) {
2460     my $part_pkg = $cust_pkg->part_pkg;
2461     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
2462                                                  ? ()
2463                                                  : ( 'null' => 1 )
2464                                    )
2465       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
2466
2467     if ($error) {
2468       $dbh->rollback if $oldAutoCommit;
2469       return "setting usage values: $error";
2470     }
2471   } else {
2472     # if NOT changing pkgpart, transfer any usage pools over
2473     foreach my $usage ($self->cust_pkg_usage) {
2474       $usage->set('pkgnum', $cust_pkg->pkgnum);
2475       $error = $usage->replace;
2476       if ( $error ) {
2477         $dbh->rollback if $oldAutoCommit;
2478         return "transferring usage pools: $error";
2479       }
2480     }
2481   }
2482
2483   # transfer usage pricing add-ons, if we're not changing pkgpart or if they were specified
2484   if ( $same_pkgpart || $opt->{'cust_pkg_usageprice'}) {
2485     my @old_cust_pkg_usageprice;
2486     if ($opt->{'cust_pkg_usageprice'}) {
2487       @old_cust_pkg_usageprice = @{ $opt->{'cust_pkg_usageprice'} };
2488     } else {
2489       @old_cust_pkg_usageprice = $self->cust_pkg_usageprice;
2490     }
2491     foreach my $old_cust_pkg_usageprice (@old_cust_pkg_usageprice) {
2492       my $new_cust_pkg_usageprice = new FS::cust_pkg_usageprice {
2493         'pkgnum'         => $cust_pkg->pkgnum,
2494         'usagepricepart' => $old_cust_pkg_usageprice->usagepricepart,
2495         'quantity'       => $old_cust_pkg_usageprice->quantity,
2496       };
2497       $error = $new_cust_pkg_usageprice->insert;
2498       if ( $error ) {
2499         $dbh->rollback if $oldAutoCommit;
2500         return "Error transferring usage pricing add-on: $error";
2501       }
2502     }
2503   }
2504
2505   # transfer discounts, if we're not changing pkgpart
2506   if ( $same_pkgpart ) {
2507     foreach my $old_discount ($self->cust_pkg_discount_active) {
2508       # don't remove the old discount, we may still need to bill that package.
2509       my $new_discount = new FS::cust_pkg_discount {
2510         'pkgnum'      => $cust_pkg->pkgnum,
2511         'discountnum' => $old_discount->discountnum,
2512         'months_used' => $old_discount->months_used,
2513       };
2514       $error = $new_discount->insert;
2515       if ( $error ) {
2516         $dbh->rollback if $oldAutoCommit;
2517         return "transferring discounts: $error";
2518       }
2519     }
2520   }
2521
2522   # transfer (copy) invoice details
2523   foreach my $detail ($self->cust_pkg_detail) {
2524     my $new_detail = FS::cust_pkg_detail->new({ $detail->hash });
2525     $new_detail->set('pkgdetailnum', '');
2526     $new_detail->set('pkgnum', $cust_pkg->pkgnum);
2527     $error = $new_detail->insert;
2528     if ( $error ) {
2529       $dbh->rollback if $oldAutoCommit;
2530       return "transferring package notes: $error";
2531     }
2532   }
2533   
2534   my @new_supp_pkgs;
2535
2536   if ( !$opt->{'cust_pkg'} ) {
2537     # Order any supplemental packages.
2538     my $part_pkg = $cust_pkg->part_pkg;
2539     my @old_supp_pkgs = $self->supplemental_pkgs;
2540     foreach my $link ($part_pkg->supp_part_pkg_link) {
2541       my $old;
2542       foreach (@old_supp_pkgs) {
2543         if ($_->pkgpart == $link->dst_pkgpart) {
2544           $old = $_;
2545           $_->pkgpart(0); # so that it can't match more than once
2546         }
2547         last if $old;
2548       }
2549       # false laziness with FS::cust_main::Packages::order_pkg
2550       my $new = FS::cust_pkg->new({
2551           pkgpart       => $link->dst_pkgpart,
2552           pkglinknum    => $link->pkglinknum,
2553           custnum       => $custnum,
2554           main_pkgnum   => $cust_pkg->pkgnum,
2555           locationnum   => $cust_pkg->locationnum,
2556           start_date    => $cust_pkg->start_date,
2557           order_date    => $cust_pkg->order_date,
2558           expire        => $cust_pkg->expire,
2559           adjourn       => $cust_pkg->adjourn,
2560           contract_end  => $cust_pkg->contract_end,
2561           refnum        => $cust_pkg->refnum,
2562           discountnum   => $cust_pkg->discountnum,
2563           waive_setup   => $cust_pkg->waive_setup,
2564       });
2565       if ( $old and $opt->{'keep_dates'} ) {
2566         foreach (qw(setup bill last_bill)) {
2567           $new->set($_, $old->get($_));
2568         }
2569       }
2570       $error = $new->insert( allow_pkgpart => $same_pkgpart );
2571       # transfer services
2572       if ( $old ) {
2573         $error ||= $old->transfer($new);
2574       }
2575       if ( $error and $error > 0 ) {
2576         # no reason why this should ever fail, but still...
2577         $error = "Unable to transfer all services from supplemental package ".
2578           $old->pkgnum;
2579       }
2580       if ( $error ) {
2581         $dbh->rollback if $oldAutoCommit;
2582         return $error;
2583       }
2584       push @new_supp_pkgs, $new;
2585     }
2586   } # if !$opt->{'cust_pkg'}
2587     # because if there is one, then supplemental packages would already
2588     # have been created for it.
2589
2590   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
2591   #remaining time.
2592   #Don't allow billing the package (preceding period packages and/or 
2593   #outstanding usage) if we are keeping dates (i.e. location changing), 
2594   #because the new package will be billed for the same date range.
2595   #Supplemental packages are also canceled here.
2596
2597   # during scheduled changes, avoid canceling the package we just
2598   # changed to (duh)
2599   $self->set('change_to_pkgnum' => '');
2600
2601   $error = $self->cancel(
2602     quiet          => 1, 
2603     unused_credit  => $unused_credit,
2604     nobill         => $keep_dates,
2605     change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
2606     no_delay_cancel => 1,
2607   );
2608   if ($error) {
2609     $dbh->rollback if $oldAutoCommit;
2610     return "canceling old package: $error";
2611   }
2612
2613   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
2614     #$self->cust_main
2615     my $error = $cust_pkg->cust_main->bill( 
2616       'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
2617     );
2618     if ( $error ) {
2619       $dbh->rollback if $oldAutoCommit;
2620       return "billing new package: $error";
2621     }
2622   }
2623
2624   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2625
2626   $cust_pkg;
2627
2628 }
2629
2630 =item change_later OPTION => VALUE...
2631
2632 Schedule a package change for a later date.  This actually orders the new
2633 package immediately, but sets its start date for a future date, and sets
2634 the current package to expire on the same date.
2635
2636 If the package is already scheduled for a change, this can be called with 
2637 'start_date' to change the scheduled date, or with pkgpart and/or 
2638 locationnum to modify the package change.  To cancel the scheduled change 
2639 entirely, see C<abort_change>.
2640
2641 Options include:
2642
2643 =over 4
2644
2645 =item start_date
2646
2647 The date for the package change.  Required, and must be in the future.
2648
2649 =item pkgpart
2650
2651 =item locationnum
2652
2653 =item quantity
2654
2655 =item contract_end
2656
2657 The pkgpart, locationnum, quantity and optional contract_end of the new 
2658 package, with the same meaning as in C<change>.
2659
2660 =back
2661
2662 =cut
2663
2664 sub change_later {
2665   my $self = shift;
2666   my $opt = ref($_[0]) ? shift : { @_ };
2667
2668   # check contract_end, prevent adding/removing
2669   my $error = $self->_check_change($opt);
2670   return $error if $error;
2671
2672   my $oldAutoCommit = $FS::UID::AutoCommit;
2673   local $FS::UID::AutoCommit = 0;
2674   my $dbh = dbh;
2675
2676   my $cust_main = $self->cust_main;
2677
2678   my $date = delete $opt->{'start_date'} or return 'start_date required';
2679  
2680   if ( $date <= time ) {
2681     $dbh->rollback if $oldAutoCommit;
2682     return "start_date $date is in the past";
2683   }
2684
2685   # If the user entered a new location, set it up now.
2686   if ( $opt->{'cust_location'} ) {
2687     $error = $opt->{'cust_location'}->find_or_insert;
2688     if ( $error ) {
2689       $dbh->rollback if $oldAutoCommit;
2690       return "creating location record: $error";
2691     }
2692     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
2693   }
2694
2695   if ( $self->change_to_pkgnum ) {
2696     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
2697     my $new_pkgpart = $opt->{'pkgpart'}
2698         if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart;
2699     my $new_locationnum = $opt->{'locationnum'}
2700         if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum;
2701     my $new_quantity = $opt->{'quantity'}
2702         if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity;
2703     my $new_contract_end = $opt->{'contract_end'}
2704         if $opt->{'contract_end'} and $opt->{'contract_end'} != $change_to->contract_end;
2705     if ( $new_pkgpart or $new_locationnum or $new_quantity or $new_contract_end ) {
2706       # it hasn't been billed yet, so in principle we could just edit
2707       # it in place (w/o a package change), but that's bad form.
2708       # So change the package according to the new options...
2709       my $err_or_pkg = $change_to->change(%$opt);
2710       if ( ref $err_or_pkg ) {
2711         # Then set that package up for a future start.
2712         $self->set('change_to_pkgnum', $err_or_pkg->pkgnum);
2713         $self->set('expire', $date); # in case it's different
2714         $err_or_pkg->set('start_date', $date);
2715         $err_or_pkg->set('change_date', '');
2716         $err_or_pkg->set('change_pkgnum', '');
2717
2718         $error = $self->replace       ||
2719                  $err_or_pkg->replace ||
2720                  #because change() might've edited existing scheduled change in place
2721                  (($err_or_pkg->pkgnum == $change_to->pkgnum) ? '' :
2722                   $change_to->cancel('no_delay_cancel' => 1) ||
2723                   $change_to->delete);
2724       } else {
2725         $error = $err_or_pkg;
2726       }
2727     } else { # change the start date only.
2728       $self->set('expire', $date);
2729       $change_to->set('start_date', $date);
2730       $error = $self->replace || $change_to->replace;
2731     }
2732     if ( $error ) {
2733       $dbh->rollback if $oldAutoCommit;
2734       return $error;
2735     } else {
2736       $dbh->commit if $oldAutoCommit;
2737       return '';
2738     }
2739   } # if $self->change_to_pkgnum
2740
2741   my $new_pkgpart = $opt->{'pkgpart'}
2742       if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart;
2743   my $new_locationnum = $opt->{'locationnum'}
2744       if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum;
2745   my $new_quantity = $opt->{'quantity'}
2746       if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity;
2747   my $new_contract_end = $opt->{'contract_end'}
2748       if $opt->{'contract_end'} and $opt->{'contract_end'} != $self->contract_end;
2749
2750   return '' unless $new_pkgpart or $new_locationnum or $new_quantity or $new_contract_end; # wouldn't do anything
2751
2752   # allow $opt->{'locationnum'} = '' to specifically set it to null
2753   # (i.e. customer default location)
2754   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2755
2756   my $new = FS::cust_pkg->new( {
2757     custnum     => $self->custnum,
2758     locationnum => $opt->{'locationnum'},
2759     start_date  => $date,
2760     map   {  $_ => ( $opt->{$_} || $self->$_() )  }
2761       qw( pkgpart quantity refnum salesnum contract_end )
2762   } );
2763   $error = $new->insert('change' => 1, 
2764                         'allow_pkgpart' => ($new_pkgpart ? 0 : 1));
2765   if ( !$error ) {
2766     $self->set('change_to_pkgnum', $new->pkgnum);
2767     $self->set('expire', $date);
2768     $error = $self->replace;
2769   }
2770   if ( $error ) {
2771     $dbh->rollback if $oldAutoCommit;
2772   } else {
2773     $dbh->commit if $oldAutoCommit;
2774   }
2775
2776   $error;
2777 }
2778
2779 =item abort_change
2780
2781 Cancels a future package change scheduled by C<change_later>.
2782
2783 =cut
2784
2785 sub abort_change {
2786   my $self = shift;
2787   my $oldAutoCommit = $FS::UID::AutoCommit;
2788   local $FS::UID::AutoCommit = 0;
2789
2790   my $pkgnum = $self->change_to_pkgnum;
2791   my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum;
2792   my $error;
2793   $self->set('change_to_pkgnum', '');
2794   $self->set('expire', '');
2795   $error = $self->replace;
2796   if ( $change_to ) {
2797     $error ||= $change_to->cancel || $change_to->delete;
2798   }
2799
2800   if ( $oldAutoCommit ) {
2801     if ( $error ) {
2802       dbh->rollback;
2803     } else {
2804       dbh->commit;
2805     }
2806   }
2807
2808   return $error;
2809 }
2810
2811 =item set_quantity QUANTITY
2812
2813 Change the package's quantity field.  This is one of the few package properties
2814 that can safely be changed without canceling and reordering the package
2815 (because it doesn't affect tax eligibility).  Returns an error or an 
2816 empty string.
2817
2818 =cut
2819
2820 sub set_quantity {
2821   my $self = shift;
2822   $self = $self->replace_old; # just to make sure
2823   $self->quantity(shift);
2824   $self->replace;
2825 }
2826
2827 =item set_salesnum SALESNUM
2828
2829 Change the package's salesnum (sales person) field.  This is one of the few
2830 package properties that can safely be changed without canceling and reordering
2831 the package (because it doesn't affect tax eligibility).  Returns an error or
2832 an empty string.
2833
2834 =cut
2835
2836 sub set_salesnum {
2837   my $self = shift;
2838   $self = $self->replace_old; # just to make sure
2839   $self->salesnum(shift);
2840   $self->replace;
2841   # XXX this should probably reassign any credit that's already been given
2842 }
2843
2844 =item modify_charge OPTIONS
2845
2846 Change the properties of a one-time charge.  The following properties can
2847 be changed this way:
2848 - pkg: the package description
2849 - classnum: the package class
2850 - additional: arrayref of additional invoice details to add to this package
2851
2852 and, I<if the charge has not yet been billed>:
2853 - start_date: the date when it will be billed
2854 - amount: the setup fee to be charged
2855 - quantity: the multiplier for the setup fee
2856 - separate_bill: whether to put the charge on a separate invoice
2857
2858 If you pass 'adjust_commission' => 1, and the classnum changes, and there are
2859 commission credits linked to this charge, they will be recalculated.
2860
2861 =cut
2862
2863 sub modify_charge {
2864   my $self = shift;
2865   my %opt = @_;
2866   my $part_pkg = $self->part_pkg;
2867   my $pkgnum = $self->pkgnum;
2868
2869   my $dbh = dbh;
2870   my $oldAutoCommit = $FS::UID::AutoCommit;
2871   local $FS::UID::AutoCommit = 0;
2872
2873   return "Can't use modify_charge except on one-time charges"
2874     unless $part_pkg->freq eq '0';
2875
2876   if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) {
2877     $part_pkg->set('pkg', $opt{'pkg'});
2878   }
2879
2880   my %pkg_opt = $part_pkg->options;
2881   my $pkg_opt_modified = 0;
2882
2883   $opt{'additional'} ||= [];
2884   my $i;
2885   my @old_additional;
2886   foreach (grep /^additional/, keys %pkg_opt) {
2887     ($i) = ($_ =~ /^additional_info(\d+)$/);
2888     $old_additional[$i] = $pkg_opt{$_} if $i;
2889     delete $pkg_opt{$_};
2890   }
2891
2892   for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) {
2893     $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i];
2894     if (!exists($old_additional[$i])
2895         or $old_additional[$i] ne $opt{'additional'}->[$i])
2896     {
2897       $pkg_opt_modified = 1;
2898     }
2899   }
2900   $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i;
2901   $pkg_opt{'additional_count'} = $i if $i > 0;
2902
2903   my $old_classnum;
2904   if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} )
2905   {
2906     # remember it
2907     $old_classnum = $part_pkg->classnum;
2908     $part_pkg->set('classnum', $opt{'classnum'});
2909   }
2910
2911   if ( !$self->get('setup') ) {
2912     # not yet billed, so allow amount, setup_cost, quantity, start_date,
2913     # and separate_bill
2914
2915     if ( exists($opt{'amount'}) 
2916           and $part_pkg->option('setup_fee') != $opt{'amount'}
2917           and $opt{'amount'} > 0 ) {
2918
2919       $pkg_opt{'setup_fee'} = $opt{'amount'};
2920       $pkg_opt_modified = 1;
2921     }
2922
2923     if ( exists($opt{'setup_cost'}) 
2924           and $part_pkg->setup_cost != $opt{'setup_cost'}
2925           and $opt{'setup_cost'} > 0 ) {
2926
2927       $part_pkg->set('setup_cost', $opt{'setup_cost'});
2928     }
2929
2930     if ( exists($opt{'quantity'})
2931           and $opt{'quantity'} != $self->quantity
2932           and $opt{'quantity'} > 0 ) {
2933         
2934       $self->set('quantity', $opt{'quantity'});
2935     }
2936
2937     if ( exists($opt{'start_date'})
2938           and $opt{'start_date'} != $self->start_date ) {
2939
2940       $self->set('start_date', $opt{'start_date'});
2941     }
2942
2943     if ( exists($opt{'separate_bill'})
2944           and $opt{'separate_bill'} ne $self->separate_bill ) {
2945
2946       $self->set('separate_bill', $opt{'separate_bill'});
2947     }
2948
2949
2950   } # else simply ignore them; the UI shouldn't allow editing the fields
2951
2952   
2953   if ( exists($opt{'taxclass'}) 
2954           and $part_pkg->taxclass ne $opt{'taxclass'}) {
2955     
2956       $part_pkg->set('taxclass', $opt{'taxclass'});
2957   }
2958
2959   my $error;
2960   if ( $part_pkg->modified or $pkg_opt_modified ) {
2961     # can we safely modify the package def?
2962     # Yes, if it's not available for purchase, and this is the only instance
2963     # of it.
2964     if ( $part_pkg->disabled
2965          and FS::cust_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 1
2966          and FS::quotation_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 0
2967        ) {
2968       $error = $part_pkg->replace( options => \%pkg_opt );
2969     } else {
2970       # clone it
2971       $part_pkg = $part_pkg->clone;
2972       $part_pkg->set('disabled' => 'Y');
2973       $error = $part_pkg->insert( options => \%pkg_opt );
2974       # and associate this as yet-unbilled package to the new package def
2975       $self->set('pkgpart' => $part_pkg->pkgpart);
2976     }
2977     if ( $error ) {
2978       $dbh->rollback if $oldAutoCommit;
2979       return $error;
2980     }
2981   }
2982
2983   if ($self->modified) { # for quantity or start_date change, or if we had
2984                          # to clone the existing package def
2985     my $error = $self->replace;
2986     return $error if $error;
2987   }
2988   if (defined $old_classnum) {
2989     # fix invoice grouping records
2990     my $old_catname = $old_classnum
2991                       ? FS::pkg_class->by_key($old_classnum)->categoryname
2992                       : '';
2993     my $new_catname = $opt{'classnum'}
2994                       ? $part_pkg->pkg_class->categoryname
2995                       : '';
2996     if ( $old_catname ne $new_catname ) {
2997       foreach my $cust_bill_pkg ($self->cust_bill_pkg) {
2998         # (there should only be one...)
2999         my @display = qsearch( 'cust_bill_pkg_display', {
3000             'billpkgnum'  => $cust_bill_pkg->billpkgnum,
3001             'section'     => $old_catname,
3002         });
3003         foreach (@display) {
3004           $_->set('section', $new_catname);
3005           $error = $_->replace;
3006           if ( $error ) {
3007             $dbh->rollback if $oldAutoCommit;
3008             return $error;
3009           }
3010         }
3011       } # foreach $cust_bill_pkg
3012     }
3013
3014     if ( $opt{'adjust_commission'} ) {
3015       # fix commission credits...tricky.
3016       foreach my $cust_event ($self->cust_event) {
3017         my $part_event = $cust_event->part_event;
3018         foreach my $table (qw(sales agent)) {
3019           my $class =
3020             "FS::part_event::Action::Mixin::credit_${table}_pkg_class";
3021           my $credit = qsearchs('cust_credit', {
3022               'eventnum' => $cust_event->eventnum,
3023           });
3024           if ( $part_event->isa($class) ) {
3025             # Yes, this results in current commission rates being applied 
3026             # retroactively to a one-time charge.  For accounting purposes 
3027             # there ought to be some kind of time limit on doing this.
3028             my $amount = $part_event->_calc_credit($self);
3029             if ( $credit and $credit->amount ne $amount ) {
3030               # Void the old credit.
3031               $error = $credit->void('Package class changed');
3032               if ( $error ) {
3033                 $dbh->rollback if $oldAutoCommit;
3034                 return "$error (adjusting commission credit)";
3035               }
3036             }
3037             # redo the event action to recreate the credit.
3038             local $@ = '';
3039             eval { $part_event->do_action( $self, $cust_event ) };
3040             if ( $@ ) {
3041               $dbh->rollback if $oldAutoCommit;
3042               return $@;
3043             }
3044           } # if $part_event->isa($class)
3045         } # foreach $table
3046       } # foreach $cust_event
3047     } # if $opt{'adjust_commission'}
3048   } # if defined $old_classnum
3049
3050   $dbh->commit if $oldAutoCommit;
3051   '';
3052 }
3053
3054
3055
3056 use Data::Dumper;
3057 sub process_bulk_cust_pkg {
3058   my $job = shift;
3059   my $param = shift;
3060   warn Dumper($param) if $DEBUG;
3061
3062   my $old_part_pkg = qsearchs('part_pkg', 
3063                               { pkgpart => $param->{'old_pkgpart'} });
3064   my $new_part_pkg = qsearchs('part_pkg',
3065                               { pkgpart => $param->{'new_pkgpart'} });
3066   die "Must select a new package type\n" unless $new_part_pkg;
3067   #my $keep_dates = $param->{'keep_dates'} || 0;
3068   my $keep_dates = 1; # there is no good reason to turn this off
3069
3070   my $oldAutoCommit = $FS::UID::AutoCommit;
3071   local $FS::UID::AutoCommit = 0;
3072   my $dbh = dbh;
3073
3074   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
3075
3076   my $i = 0;
3077   foreach my $old_cust_pkg ( @cust_pkgs ) {
3078     $i++;
3079     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
3080     if ( $old_cust_pkg->getfield('cancel') ) {
3081       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
3082         $old_cust_pkg->pkgnum."\n"
3083         if $DEBUG;
3084       next;
3085     }
3086     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
3087       if $DEBUG;
3088     my $error = $old_cust_pkg->change(
3089       'pkgpart'     => $param->{'new_pkgpart'},
3090       'keep_dates'  => $keep_dates
3091     );
3092     if ( !ref($error) ) { # change returns the cust_pkg on success
3093       $dbh->rollback;
3094       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
3095     }
3096   }
3097   $dbh->commit if $oldAutoCommit;
3098   return;
3099 }
3100
3101 =item last_bill
3102
3103 Returns the last bill date, or if there is no last bill date, the setup date.
3104 Useful for billing metered services.
3105
3106 =cut
3107
3108 sub last_bill {
3109   my $self = shift;
3110   return $self->setfield('last_bill', $_[0]) if @_;
3111   return $self->getfield('last_bill') if $self->getfield('last_bill');
3112   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
3113                                                   'edate'  => $self->bill,  } );
3114   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
3115 }
3116
3117 =item last_cust_pkg_reason ACTION
3118
3119 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
3120 Returns false if there is no reason or the package is not currenly ACTION'd
3121 ACTION is one of adjourn, susp, cancel, or expire.
3122
3123 =cut
3124
3125 sub last_cust_pkg_reason {
3126   my ( $self, $action ) = ( shift, shift );
3127   my $date = $self->get($action);
3128   qsearchs( {
3129               'table' => 'cust_pkg_reason',
3130               'hashref' => { 'pkgnum' => $self->pkgnum,
3131                              'action' => substr(uc($action), 0, 1),
3132                              'date'   => $date,
3133                            },
3134               'order_by' => 'ORDER BY num DESC LIMIT 1',
3135            } );
3136 }
3137
3138 =item last_reason ACTION
3139
3140 Returns the most recent ACTION FS::reason associated with the package.
3141 Returns false if there is no reason or the package is not currenly ACTION'd
3142 ACTION is one of adjourn, susp, cancel, or expire.
3143
3144 =cut
3145
3146 sub last_reason {
3147   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
3148   $cust_pkg_reason->reason
3149     if $cust_pkg_reason;
3150 }
3151
3152 =item part_pkg
3153
3154 Returns the definition for this billing item, as an FS::part_pkg object (see
3155 L<FS::part_pkg>).
3156
3157 =cut
3158
3159 sub part_pkg {
3160   my $self = shift;
3161   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
3162   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
3163   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
3164 }
3165
3166 =item old_cust_pkg
3167
3168 Returns the cancelled package this package was changed from, if any.
3169
3170 =cut
3171
3172 sub old_cust_pkg {
3173   my $self = shift;
3174   return '' unless $self->change_pkgnum;
3175   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
3176 }
3177
3178 =item change_cust_main
3179
3180 Returns the customter this package was detached to, if any.
3181
3182 =cut
3183
3184 sub change_cust_main {
3185   my $self = shift;
3186   return '' unless $self->change_custnum;
3187   qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
3188 }
3189
3190 =item calc_setup
3191
3192 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
3193 item.
3194
3195 =cut
3196
3197 sub calc_setup {
3198   my $self = shift;
3199   $self->part_pkg->calc_setup($self, @_);
3200 }
3201
3202 =item calc_recur
3203
3204 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
3205 item.
3206
3207 =cut
3208
3209 sub calc_recur {
3210   my $self = shift;
3211   $self->part_pkg->calc_recur($self, @_);
3212 }
3213
3214 =item base_setup
3215
3216 Calls the I<base_setup> of the FS::part_pkg object associated with this billing
3217 item.
3218
3219 =cut
3220
3221 sub base_setup {
3222   my $self = shift;
3223   $self->part_pkg->base_setup($self, @_);
3224 }
3225
3226 =item base_recur
3227
3228 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
3229 item.
3230
3231 =cut
3232
3233 sub base_recur {
3234   my $self = shift;
3235   $self->part_pkg->base_recur($self, @_);
3236 }
3237
3238 =item calc_remain
3239
3240 Calls the I<calc_remain> of the FS::part_pkg object associated with this
3241 billing item.
3242
3243 =cut
3244
3245 sub calc_remain {
3246   my $self = shift;
3247   $self->part_pkg->calc_remain($self, @_);
3248 }
3249
3250 =item calc_cancel
3251
3252 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
3253 billing item.
3254
3255 =cut
3256
3257 sub calc_cancel {
3258   my $self = shift;
3259   $self->part_pkg->calc_cancel($self, @_);
3260 }
3261
3262 =item cust_bill_pkg
3263
3264 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
3265
3266 =cut
3267
3268 sub cust_bill_pkg {
3269   my $self = shift;
3270   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
3271 }
3272
3273 =item cust_pkg_detail [ DETAILTYPE ]
3274
3275 Returns any customer package details for this package (see
3276 L<FS::cust_pkg_detail>).
3277
3278 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
3279
3280 =cut
3281
3282 sub cust_pkg_detail {
3283   my $self = shift;
3284   my %hash = ( 'pkgnum' => $self->pkgnum );
3285   $hash{detailtype} = shift if @_;
3286   qsearch({
3287     'table'    => 'cust_pkg_detail',
3288     'hashref'  => \%hash,
3289     'order_by' => 'ORDER BY weight, pkgdetailnum',
3290   });
3291 }
3292
3293 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
3294
3295 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
3296
3297 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
3298
3299 If there is an error, returns the error, otherwise returns false.
3300
3301 =cut
3302
3303 sub set_cust_pkg_detail {
3304   my( $self, $detailtype, @details ) = @_;
3305
3306   my $oldAutoCommit = $FS::UID::AutoCommit;
3307   local $FS::UID::AutoCommit = 0;
3308   my $dbh = dbh;
3309
3310   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
3311     my $error = $current->delete;
3312     if ( $error ) {
3313       $dbh->rollback if $oldAutoCommit;
3314       return "error removing old detail: $error";
3315     }
3316   }
3317
3318   foreach my $detail ( @details ) {
3319     my $cust_pkg_detail = new FS::cust_pkg_detail {
3320       'pkgnum'     => $self->pkgnum,
3321       'detailtype' => $detailtype,
3322       'detail'     => $detail,
3323     };
3324     my $error = $cust_pkg_detail->insert;
3325     if ( $error ) {
3326       $dbh->rollback if $oldAutoCommit;
3327       return "error adding new detail: $error";
3328     }
3329
3330   }
3331
3332   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3333   '';
3334
3335 }
3336
3337 =item cust_event
3338
3339 Returns the customer billing events (see L<FS::cust_event>) for this invoice.
3340
3341 =cut
3342
3343 #false laziness w/cust_bill.pm
3344 sub cust_event {
3345   my $self = shift;
3346   qsearch({
3347     'table'     => 'cust_event',
3348     'addl_from' => 'JOIN part_event USING ( eventpart )',
3349     'hashref'   => { 'tablenum' => $self->pkgnum },
3350     'extra_sql' => " AND eventtable = 'cust_pkg' ",
3351   });
3352 }
3353
3354 =item num_cust_event
3355
3356 Returns the number of customer billing events (see L<FS::cust_event>) for this package.
3357
3358 =cut
3359
3360 #false laziness w/cust_bill.pm
3361 sub num_cust_event {
3362   my $self = shift;
3363   my $sql = "SELECT COUNT(*) ". $self->_from_cust_event_where;
3364   $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref->[0];
3365 }
3366
3367 =item exists_cust_event
3368
3369 Returns true if there are customer billing events (see L<FS::cust_event>) for this package.  More efficient than using num_cust_event.
3370
3371 =cut
3372
3373 sub exists_cust_event {
3374   my $self = shift;
3375   my $sql = "SELECT 1 ". $self->_from_cust_event_where. " LIMIT 1";
3376   my $row = $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref;
3377   $row ? $row->[0] : '';
3378 }
3379
3380 sub _from_cust_event_where {
3381   #my $self = shift;
3382   " FROM cust_event JOIN part_event USING ( eventpart ) ".
3383   "  WHERE tablenum = ? AND eventtable = 'cust_pkg' ";
3384 }
3385
3386 sub _prep_ex {
3387   my( $self, $sql, @args ) = @_;
3388   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
3389   $sth->execute(@args)         or die $sth->errstr. " executing $sql";
3390   $sth;
3391 }
3392
3393 =item part_pkg_currency_option OPTIONNAME
3394
3395 Returns a two item list consisting of the currency of this customer, if any,
3396 and a value for the provided option.  If the customer has a currency, the value
3397 is the option value the given name and the currency (see
3398 L<FS::part_pkg_currency>).  Otherwise, if the customer has no currency, is the
3399 regular option value for the given name (see L<FS::part_pkg_option>).
3400
3401 =cut
3402
3403 sub part_pkg_currency_option {
3404   my( $self, $optionname ) = @_;
3405   my $part_pkg = $self->part_pkg;
3406   if ( my $currency = $self->cust_main->currency ) {
3407     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
3408   } else {
3409     ('', $part_pkg->option($optionname) );
3410   }
3411 }
3412
3413 =item cust_svc [ SVCPART ] (old, deprecated usage)
3414
3415 =item cust_svc [ OPTION => VALUE ... ] (current usage)
3416
3417 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
3418
3419 Returns the services for this package, as FS::cust_svc objects (see
3420 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
3421 spcififed, returns only the matching services.
3422
3423 As an optimization, use the cust_svc_unsorted version if you are not displaying
3424 the results.
3425
3426 =cut
3427
3428 sub cust_svc {
3429   my $self = shift;
3430   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
3431   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) );
3432 }
3433
3434 sub cust_svc_unsorted {
3435   my $self = shift;
3436   @{ $self->cust_svc_unsorted_arrayref(@_) };
3437 }
3438
3439 sub cust_svc_unsorted_arrayref {
3440   my $self = shift;
3441
3442   return [] unless $self->num_cust_svc(@_);
3443
3444   my %opt = ();
3445   if ( @_ && $_[0] =~ /^\d+/ ) {
3446     $opt{svcpart} = shift;
3447   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3448     %opt = %{ $_[0] };
3449   } elsif ( @_ ) {
3450     %opt = @_;
3451   }
3452
3453   my %search = (
3454     'select'    => 'cust_svc.*, part_svc.*',
3455     'table'     => 'cust_svc',
3456     'hashref'   => { 'pkgnum' => $self->pkgnum },
3457     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )',
3458   );
3459   $search{hashref}->{svcpart} = $opt{svcpart}
3460     if $opt{svcpart};
3461   $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{svcdb} )
3462     if $opt{svcdb};
3463
3464   [ qsearch(\%search) ];
3465
3466 }
3467
3468 =item overlimit [ SVCPART ]
3469
3470 Returns the services for this package which have exceeded their
3471 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
3472 is specified, return only the matching services.
3473
3474 =cut
3475
3476 sub overlimit {
3477   my $self = shift;
3478   return () unless $self->num_cust_svc(@_);
3479   grep { $_->overlimit } $self->cust_svc(@_);
3480 }
3481
3482 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3483
3484 Returns historical services for this package created before END TIMESTAMP and
3485 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
3486 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
3487 I<pkg_svc.hidden> flag will be omitted.
3488
3489 =cut
3490
3491 sub h_cust_svc {
3492   my $self = shift;
3493   warn "$me _h_cust_svc called on $self\n"
3494     if $DEBUG;
3495
3496   my ($end, $start, $mode) = @_;
3497
3498   local($FS::Record::qsearch_qualify_columns) = 0;
3499
3500   my @cust_svc = $self->_sort_cust_svc(
3501     [ qsearch( 'h_cust_svc',
3502       { 'pkgnum' => $self->pkgnum, },  
3503       FS::h_cust_svc->sql_h_search(@_),  
3504     ) ]
3505   );
3506
3507   if ( defined($mode) && $mode eq 'I' ) {
3508     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
3509     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
3510   } else {
3511     return @cust_svc;
3512   }
3513 }
3514
3515 sub _sort_cust_svc {
3516   my( $self, $arrayref ) = @_;
3517
3518   my $sort =
3519     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
3520
3521   my %pkg_svc = map { $_->svcpart => $_ }
3522                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
3523
3524   map  { $_->[0] }
3525   sort $sort
3526   map {
3527         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
3528         [ $_,
3529           $pkg_svc ? $pkg_svc->primary_svc : '',
3530           $pkg_svc ? $pkg_svc->quantity : 0,
3531         ];
3532       }
3533   @$arrayref;
3534
3535 }
3536
3537 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
3538
3539 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
3540
3541 Returns the number of services for this package.  Available options are svcpart
3542 and svcdb.  If either is spcififed, returns only the matching services.
3543
3544 =cut
3545
3546 sub num_cust_svc {
3547   my $self = shift;
3548
3549   return $self->{'_num_cust_svc'}
3550     if !scalar(@_)
3551        && exists($self->{'_num_cust_svc'})
3552        && $self->{'_num_cust_svc'} =~ /\d/;
3553
3554   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
3555     if $DEBUG > 2;
3556
3557   my %opt = ();
3558   if ( @_ && $_[0] =~ /^\d+/ ) {
3559     $opt{svcpart} = shift;
3560   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3561     %opt = %{ $_[0] };
3562   } elsif ( @_ ) {
3563     %opt = @_;
3564   }
3565
3566   my $select = 'SELECT COUNT(*) FROM cust_svc ';
3567   my $where = ' WHERE pkgnum = ? ';
3568   my @param = ($self->pkgnum);
3569
3570   if ( $opt{'svcpart'} ) {
3571     $where .= ' AND svcpart = ? ';
3572     push @param, $opt{'svcpart'};
3573   }
3574   if ( $opt{'svcdb'} ) {
3575     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
3576     $where .= ' AND svcdb = ? ';
3577     push @param, $opt{'svcdb'};
3578   }
3579
3580   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
3581   $sth->execute(@param) or die $sth->errstr;
3582   $sth->fetchrow_arrayref->[0];
3583 }
3584
3585 =item available_part_svc 
3586
3587 Returns a list of FS::part_svc objects representing services included in this
3588 package but not yet provisioned.  Each FS::part_svc object also has an extra
3589 field, I<num_avail>, which specifies the number of available services.
3590
3591 Accepts option I<provision_hold>;  if true, only returns part_svc for which the
3592 associated pkg_svc has the provision_hold flag set.
3593
3594 =cut
3595
3596 sub available_part_svc {
3597   my $self = shift;
3598   my %opt  = @_;
3599
3600   my $pkg_quantity = $self->quantity || 1;
3601
3602   grep { $_->num_avail > 0 }
3603   map {
3604     my $part_svc = $_->part_svc;
3605     $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
3606     $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
3607
3608     # more evil encapsulation breakage
3609     if ($part_svc->{'Hash'}{'num_avail'} > 0) {
3610       my @exports = $part_svc->part_export_did;
3611       $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
3612         }
3613
3614     $part_svc;
3615   }
3616   grep { $opt{'provision_hold'} ? $_->provision_hold : 1 }
3617   $self->part_pkg->pkg_svc;
3618 }
3619
3620 =item part_svc [ OPTION => VALUE ... ]
3621
3622 Returns a list of FS::part_svc objects representing provisioned and available
3623 services included in this package.  Each FS::part_svc object also has the
3624 following extra fields:
3625
3626 =over 4
3627
3628 =item num_cust_svc
3629
3630 (count)
3631
3632 =item num_avail
3633
3634 (quantity - count)
3635
3636 =item cust_pkg_svc
3637
3638 (services) - array reference containing the provisioned services, as cust_svc objects
3639
3640 =back
3641
3642 Accepts two options:
3643
3644 =over 4
3645
3646 =item summarize_size
3647
3648 If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc
3649 is this size or greater.
3650
3651 =item hide_discontinued
3652
3653 If true, will omit looking for services that are no longer avaialble in the
3654 package definition.
3655
3656 =back
3657
3658 =cut
3659
3660 #svcnum
3661 #label -> ($cust_svc->label)[1]
3662
3663 sub part_svc {
3664   my $self = shift;
3665   my %opt = @_;
3666
3667   my $pkg_quantity = $self->quantity || 1;
3668
3669   #XXX some sort of sort order besides numeric by svcpart...
3670   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
3671     my $pkg_svc = $_;
3672     my $part_svc = $pkg_svc->part_svc;
3673     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3674     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
3675     $part_svc->{'Hash'}{'num_avail'}    =
3676       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
3677     $part_svc->{'Hash'}{'cust_pkg_svc'} =
3678         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
3679       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
3680           && $num_cust_svc >= $opt{summarize_size};
3681     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
3682     $part_svc;
3683   } $self->part_pkg->pkg_svc;
3684
3685   unless ( $opt{hide_discontinued} ) {
3686     #extras
3687     push @part_svc, map {
3688       my $part_svc = $_;
3689       my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3690       $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
3691       $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
3692       $part_svc->{'Hash'}{'cust_pkg_svc'} =
3693         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
3694       $part_svc;
3695     } $self->extra_part_svc;
3696   }
3697
3698   @part_svc;
3699
3700 }
3701
3702 =item extra_part_svc
3703
3704 Returns a list of FS::part_svc objects corresponding to services in this
3705 package which are still provisioned but not (any longer) available in the
3706 package definition.
3707
3708 =cut
3709
3710 sub extra_part_svc {
3711   my $self = shift;
3712
3713   my $pkgnum  = $self->pkgnum;
3714   #my $pkgpart = $self->pkgpart;
3715
3716 #  qsearch( {
3717 #    'table'     => 'part_svc',
3718 #    'hashref'   => {},
3719 #    'extra_sql' =>
3720 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
3721 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
3722 #                       AND pkg_svc.pkgpart = ?
3723 #                       AND quantity > 0 
3724 #                 )
3725 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
3726 #                       LEFT JOIN cust_pkg USING ( pkgnum )
3727 #                     WHERE cust_svc.svcpart = part_svc.svcpart
3728 #                       AND pkgnum = ?
3729 #                 )",
3730 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
3731 #  } );
3732
3733 #seems to benchmark slightly faster... (or did?)
3734
3735   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
3736   my $pkgparts = join(',', @pkgparts);
3737
3738   qsearch( {
3739     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
3740     #MySQL doesn't grok DISINCT ON
3741     'select'      => 'DISTINCT part_svc.*',
3742     'table'       => 'part_svc',
3743     'addl_from'   =>
3744       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
3745                                AND pkg_svc.pkgpart IN ($pkgparts)
3746                                AND quantity > 0
3747                              )
3748        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
3749        LEFT JOIN cust_pkg USING ( pkgnum )
3750       ",
3751     'hashref'     => {},
3752     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
3753     'extra_param' => [ [$self->pkgnum=>'int'] ],
3754   } );
3755 }
3756
3757 =item status
3758
3759 Returns a short status string for this package, currently:
3760
3761 =over 4
3762
3763 =item on hold
3764
3765 =item not yet billed
3766
3767 =item one-time charge
3768
3769 =item active
3770
3771 =item suspended
3772
3773 =item cancelled
3774
3775 =back
3776
3777 =cut
3778
3779 sub status {
3780   my $self = shift;
3781
3782   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
3783
3784   return 'cancelled' if $self->get('cancel');
3785   return 'on hold' if $self->susp && ! $self->setup;
3786   return 'suspended' if $self->susp;
3787   return 'not yet billed' unless $self->setup;
3788   return 'one-time charge' if $freq =~ /^(0|$)/;
3789   return 'active';
3790 }
3791
3792 =item ucfirst_status
3793
3794 Returns the status with the first character capitalized.
3795
3796 =cut
3797
3798 sub ucfirst_status {
3799   ucfirst(shift->status);
3800 }
3801
3802 =item statuses
3803
3804 Class method that returns the list of possible status strings for packages
3805 (see L<the status method|/status>).  For example:
3806
3807   @statuses = FS::cust_pkg->statuses();
3808
3809 =cut
3810
3811 tie my %statuscolor, 'Tie::IxHash', 
3812   'on hold'         => 'FF00F5', #brighter purple!
3813   'not yet billed'  => '009999', #teal? cyan?
3814   'one-time charge' => '0000CC', #blue  #'000000',
3815   'active'          => '00CC00',
3816   'suspended'       => 'FF9900',
3817   'cancelled'       => 'FF0000',
3818 ;
3819
3820 sub statuses {
3821   my $self = shift; #could be class...
3822   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
3823   #                                    # mayble split btw one-time vs. recur
3824     keys %statuscolor;
3825 }
3826
3827 sub statuscolors {
3828   #my $self = shift;
3829   \%statuscolor;
3830 }
3831
3832 =item statuscolor
3833
3834 Returns a hex triplet color string for this package's status.
3835
3836 =cut
3837
3838 sub statuscolor {
3839   my $self = shift;
3840   $statuscolor{$self->status};
3841 }
3842
3843 =item is_status_delay_cancel
3844
3845 Returns true if part_pkg has option delay_cancel, 
3846 cust_pkg status is 'suspended' and expire is set
3847 to cancel package within the next day (or however
3848 many days are set in global config part_pkg-delay_cancel-days.
3849
3850 Accepts option I<part_pkg-delay_cancel-days> which should be
3851 the value of the config setting, to avoid looking it up again.
3852
3853 This is not a real status, this only meant for hacking display 
3854 values, because otherwise treating the package as suspended is 
3855 really the whole point of the delay_cancel option.
3856
3857 =cut
3858
3859 sub is_status_delay_cancel {
3860   my ($self,%opt) = @_;
3861   if ( $self->main_pkgnum and $self->pkglinknum ) {
3862     return $self->main_pkg->is_status_delay_cancel;
3863   }
3864   return 0 unless $self->part_pkg->option('delay_cancel',1);
3865   return 0 unless $self->status eq 'suspended';
3866   return 0 unless $self->expire;
3867   my $expdays = $opt{'part_pkg-delay_cancel-days'};
3868   unless ($expdays) {
3869     my $conf = new FS::Conf;
3870     $expdays = $conf->config('part_pkg-delay_cancel-days') || 1;
3871   }
3872   my $expsecs = 60*60*24*$expdays;
3873   return 0 unless $self->expire < time + $expsecs;
3874   return 1;
3875 }
3876
3877 =item pkg_label
3878
3879 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3880 "pkg - comment" depending on user preference).
3881
3882 =cut
3883
3884 sub pkg_label {
3885   my $self = shift;
3886   my $label = $self->part_pkg->pkg_comment( cust_pkg=>$self, nopkgpart=>1 );
3887   $label = $self->pkgnum. ": $label"
3888     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3889   $label;
3890 }
3891
3892 =item pkg_label_long
3893
3894 Returns a long label for this package, adding the primary service's label to
3895 pkg_label.
3896
3897 =cut
3898
3899 sub pkg_label_long {
3900   my $self = shift;
3901   my $label = $self->pkg_label;
3902   my $cust_svc = $self->primary_cust_svc;
3903   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3904   $label;
3905 }
3906
3907 =item pkg_locale
3908
3909 Returns a customer-localized label for this package.
3910
3911 =cut
3912
3913 sub pkg_locale {
3914   my $self = shift;
3915   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3916 }
3917
3918 =item primary_cust_svc
3919
3920 Returns a primary service (as FS::cust_svc object) if one can be identified.
3921
3922 =cut
3923
3924 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3925
3926 sub primary_cust_svc {
3927   my $self = shift;
3928
3929   my @cust_svc = $self->cust_svc;
3930
3931   return '' unless @cust_svc; #no serivces - irrelevant then
3932   
3933   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3934
3935   # primary service as specified in the package definition
3936   # or exactly one service definition with quantity one
3937   my $svcpart = $self->part_pkg->svcpart;
3938   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3939   return $cust_svc[0] if scalar(@cust_svc) == 1;
3940
3941   #couldn't identify one thing..
3942   return '';
3943 }
3944
3945 =item labels
3946
3947 Returns a list of lists, calling the label method for all services
3948 (see L<FS::cust_svc>) of this billing item.
3949
3950 =cut
3951
3952 sub labels {
3953   my $self = shift;
3954   map { [ $_->label ] } $self->cust_svc;
3955 }
3956
3957 =item h_labels END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ]
3958
3959 Like the labels method, but returns historical information on services that
3960 were active as of END_TIMESTAMP and (optionally) not cancelled before
3961 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3962 I<pkg_svc.hidden> flag will be omitted.
3963
3964 If LOCALE is passed, service definition names will be localized.
3965
3966 Returns a list of lists, calling the label method for all (historical)
3967 services (see L<FS::h_cust_svc>) of this billing item.
3968
3969 =cut
3970
3971 sub h_labels {
3972   my $self = shift;
3973   my ($end, $start, $mode, $locale) = @_;
3974   warn "$me h_labels\n"
3975     if $DEBUG;
3976   map { [ $_->label($end, $start, $locale) ] }
3977         $self->h_cust_svc($end, $start, $mode);
3978 }
3979
3980 =item labels_short
3981
3982 Like labels, except returns a simple flat list, and shortens long
3983 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3984 identical services to one line that lists the service label and the number of
3985 individual services rather than individual items.
3986
3987 =cut
3988
3989 sub labels_short {
3990   shift->_labels_short( 'labels' ); # 'labels' takes no further arguments
3991 }
3992
3993 =item h_labels_short END_TIMESTAMP [, START_TIMESTAMP [, MODE [, LOCALE ] ] ]
3994
3995 Like h_labels, except returns a simple flat list, and shortens long
3996 (currently >5 or the cust_bill-max_same_services configuration value) lists
3997 of identical services to one line that lists the service label and the
3998 number of individual services rather than individual items.
3999
4000 =cut
4001
4002 sub h_labels_short {
4003   shift->_labels_short( 'h_labels', @_ );
4004 }
4005
4006 # takes a method name ('labels' or 'h_labels') and all its arguments;
4007 # maybe should be "shorten($self->h_labels( ... ) )"
4008
4009 sub _labels_short {
4010   my( $self, $method ) = ( shift, shift );
4011
4012   warn "$me _labels_short called on $self with $method method\n"
4013     if $DEBUG;
4014
4015   my $conf = new FS::Conf;
4016   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
4017
4018   warn "$me _labels_short populating \%labels\n"
4019     if $DEBUG;
4020
4021   my %labels;
4022   #tie %labels, 'Tie::IxHash';
4023   push @{ $labels{$_->[0]} }, $_->[1]
4024     foreach $self->$method(@_);
4025
4026   warn "$me _labels_short populating \@labels\n"
4027     if $DEBUG;
4028
4029   my @labels;
4030   foreach my $label ( keys %labels ) {
4031     my %seen = ();
4032     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
4033     my $num = scalar(@values);
4034     warn "$me _labels_short $num items for $label\n"
4035       if $DEBUG;
4036
4037     if ( $num > $max_same_services ) {
4038       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
4039         if $DEBUG;
4040       push @labels, "$label ($num)";
4041     } else {
4042       if ( $conf->exists('cust_bill-consolidate_services') ) {
4043         warn "$me _labels_short   consolidating services\n"
4044           if $DEBUG;
4045         # push @labels, "$label: ". join(', ', @values);
4046         while ( @values ) {
4047           my $detail = "$label: ";
4048           $detail .= shift(@values). ', '
4049             while @values
4050                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
4051           $detail =~ s/, $//;
4052           push @labels, $detail;
4053         }
4054         warn "$me _labels_short   done consolidating services\n"
4055           if $DEBUG;
4056       } else {
4057         warn "$me _labels_short   adding service data\n"
4058           if $DEBUG;
4059         push @labels, map { "$label: $_" } @values;
4060       }
4061     }
4062   }
4063
4064  @labels;
4065
4066 }
4067
4068 =item cust_main
4069
4070 Returns the parent customer object (see L<FS::cust_main>).
4071
4072 =item balance
4073
4074 Returns the balance for this specific package, when using
4075 experimental package balance.
4076
4077 =cut
4078
4079 sub balance {
4080   my $self = shift;
4081   $self->cust_main->balance_pkgnum( $self->pkgnum );
4082 }
4083
4084 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
4085
4086 =item cust_location
4087
4088 Returns the location object, if any (see L<FS::cust_location>).
4089
4090 =item cust_location_or_main
4091
4092 If this package is associated with a location, returns the locaiton (see
4093 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
4094
4095 =item location_label [ OPTION => VALUE ... ]
4096
4097 Returns the label of the location object (see L<FS::cust_location>).
4098
4099 =cut
4100
4101 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
4102
4103 =item tax_locationnum
4104
4105 Returns the foreign key to a L<FS::cust_location> object for calculating  
4106 tax on this package, as determined by the C<tax-pkg_address> and 
4107 C<tax-ship_address> configuration flags.
4108
4109 =cut
4110
4111 sub tax_locationnum {
4112   my $self = shift;
4113   my $conf = FS::Conf->new;
4114   if ( $conf->exists('tax-pkg_address') ) {
4115     return $self->locationnum;
4116   }
4117   elsif ( $conf->exists('tax-ship_address') ) {
4118     return $self->cust_main->ship_locationnum;
4119   }
4120   else {
4121     return $self->cust_main->bill_locationnum;
4122   }
4123 }
4124
4125 =item tax_location
4126
4127 Returns the L<FS::cust_location> object for tax_locationnum.
4128
4129 =cut
4130
4131 sub tax_location {
4132   my $self = shift;
4133   my $conf = FS::Conf->new;
4134   if ( $conf->exists('tax-pkg_address') and $self->locationnum ) {
4135     return FS::cust_location->by_key($self->locationnum);
4136   }
4137   elsif ( $conf->exists('tax-ship_address') ) {
4138     return $self->cust_main->ship_location;
4139   }
4140   else {
4141     return $self->cust_main->bill_location;
4142   }
4143 }
4144
4145 =item seconds_since TIMESTAMP
4146
4147 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
4148 package have been online since TIMESTAMP, according to the session monitor.
4149
4150 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
4151 L<Time::Local> and L<Date::Parse> for conversion functions.
4152
4153 =cut
4154
4155 sub seconds_since {
4156   my($self, $since) = @_;
4157   my $seconds = 0;
4158
4159   foreach my $cust_svc (
4160     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
4161   ) {
4162     $seconds += $cust_svc->seconds_since($since);
4163   }
4164
4165   $seconds;
4166
4167 }
4168
4169 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
4170
4171 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
4172 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
4173 (exclusive).
4174
4175 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
4176 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
4177 functions.
4178
4179
4180 =cut
4181
4182 sub seconds_since_sqlradacct {
4183   my($self, $start, $end) = @_;
4184
4185   my $seconds = 0;
4186
4187   foreach my $cust_svc (
4188     grep {
4189       my $part_svc = $_->part_svc;
4190       $part_svc->svcdb eq 'svc_acct'
4191         && scalar($part_svc->part_export_usage);
4192     } $self->cust_svc
4193   ) {
4194     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
4195   }
4196
4197   $seconds;
4198
4199 }
4200
4201 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
4202
4203 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
4204 in this package for sessions ending between TIMESTAMP_START (inclusive) and
4205 TIMESTAMP_END
4206 (exclusive).
4207
4208 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
4209 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
4210 functions.
4211
4212 =cut
4213
4214 sub attribute_since_sqlradacct {
4215   my($self, $start, $end, $attrib) = @_;
4216
4217   my $sum = 0;
4218
4219   foreach my $cust_svc (
4220     grep {
4221       my $part_svc = $_->part_svc;
4222       scalar($part_svc->part_export_usage);
4223     } $self->cust_svc
4224   ) {
4225     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
4226   }
4227
4228   $sum;
4229
4230 }
4231
4232 =item quantity
4233
4234 =cut
4235
4236 sub quantity {
4237   my( $self, $value ) = @_;
4238   if ( defined($value) ) {
4239     $self->setfield('quantity', $value);
4240   }
4241   $self->getfield('quantity') || 1;
4242 }
4243
4244 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
4245
4246 Transfers as many services as possible from this package to another package.
4247
4248 The destination package can be specified by pkgnum by passing an FS::cust_pkg
4249 object.  The destination package must already exist.
4250
4251 Services are moved only if the destination allows services with the correct
4252 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
4253 this option with caution!  No provision is made for export differences
4254 between the old and new service definitions.  Probably only should be used
4255 when your exports for all service definitions of a given svcdb are identical.
4256 (attempt a transfer without it first, to move all possible svcpart-matching
4257 services)
4258
4259 Any services that can't be moved remain in the original package.
4260
4261 Returns an error, if there is one; otherwise, returns the number of services 
4262 that couldn't be moved.
4263
4264 =cut
4265
4266 sub transfer {
4267   my ($self, $dest_pkgnum, %opt) = @_;
4268
4269   my $remaining = 0;
4270   my $dest;
4271   my %target;
4272
4273   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
4274     $dest = $dest_pkgnum;
4275     $dest_pkgnum = $dest->pkgnum;
4276   } else {
4277     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
4278   }
4279
4280   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
4281
4282   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
4283     $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 );
4284   }
4285
4286   foreach my $cust_svc ($dest->cust_svc) {
4287     $target{$cust_svc->svcpart}--;
4288   }
4289
4290   my %svcpart2svcparts = ();
4291   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4292     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
4293     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
4294       next if exists $svcpart2svcparts{$svcpart};
4295       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
4296       $svcpart2svcparts{$svcpart} = [
4297         map  { $_->[0] }
4298         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
4299         map {
4300               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
4301                                                    'svcpart' => $_          } );
4302               [ $_,
4303                 $pkg_svc ? $pkg_svc->primary_svc : '',
4304                 $pkg_svc ? $pkg_svc->quantity : 0,
4305               ];
4306             }
4307
4308         grep { $_ != $svcpart }
4309         map  { $_->svcpart }
4310         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
4311       ];
4312       warn "alternates for svcpart $svcpart: ".
4313            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
4314         if $DEBUG;
4315     }
4316   }
4317
4318   my $error;
4319   foreach my $cust_svc ($self->cust_svc) {
4320     my $svcnum = $cust_svc->svcnum;
4321     if($target{$cust_svc->svcpart} > 0
4322        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
4323       $target{$cust_svc->svcpart}--;
4324       my $new = new FS::cust_svc { $cust_svc->hash };
4325       $new->pkgnum($dest_pkgnum);
4326       $error = $new->replace($cust_svc);
4327     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4328       if ( $DEBUG ) {
4329         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
4330         warn "alternates to consider: ".
4331              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
4332       }
4333       my @alternate = grep {
4334                              warn "considering alternate svcpart $_: ".
4335                                   "$target{$_} available in new package\n"
4336                                if $DEBUG;
4337                              $target{$_} > 0;
4338                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
4339       if ( @alternate ) {
4340         warn "alternate(s) found\n" if $DEBUG;
4341         my $change_svcpart = $alternate[0];
4342         $target{$change_svcpart}--;
4343         my $new = new FS::cust_svc { $cust_svc->hash };
4344         $new->svcpart($change_svcpart);
4345         $new->pkgnum($dest_pkgnum);
4346         $error = $new->replace($cust_svc);
4347       } else {
4348         $remaining++;
4349       }
4350     } else {
4351       $remaining++
4352     }
4353     if ( $error ) {
4354       my @label = $cust_svc->label;
4355       return "$label[0] $label[1]: $error";
4356     }
4357   }
4358   return $remaining;
4359 }
4360
4361 =item grab_svcnums SVCNUM, SVCNUM ...
4362
4363 Change the pkgnum for the provided services to this packages.  If there is an
4364 error, returns the error, otherwise returns false.
4365
4366 =cut
4367
4368 sub grab_svcnums {
4369   my $self = shift;
4370   my @svcnum = @_;
4371
4372   my $oldAutoCommit = $FS::UID::AutoCommit;
4373   local $FS::UID::AutoCommit = 0;
4374   my $dbh = dbh;
4375
4376   foreach my $svcnum (@svcnum) {
4377     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
4378       $dbh->rollback if $oldAutoCommit;
4379       return "unknown svcnum $svcnum";
4380     };
4381     $cust_svc->pkgnum( $self->pkgnum );
4382     my $error = $cust_svc->replace;
4383     if ( $error ) {
4384       $dbh->rollback if $oldAutoCommit;
4385       return $error;
4386     }
4387   }
4388
4389   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4390   '';
4391
4392 }
4393
4394 =item reexport
4395
4396 This method is deprecated.  See the I<depend_jobnum> option to the insert and
4397 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
4398
4399 =cut
4400
4401 #looks like this is still used by the order_pkg and change_pkg methods in
4402 # ClientAPI/MyAccount, need to look into those before removing
4403 sub reexport {
4404   my $self = shift;
4405
4406   my $oldAutoCommit = $FS::UID::AutoCommit;
4407   local $FS::UID::AutoCommit = 0;
4408   my $dbh = dbh;
4409
4410   foreach my $cust_svc ( $self->cust_svc ) {
4411     #false laziness w/svc_Common::insert
4412     my $svc_x = $cust_svc->svc_x;
4413     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
4414       my $error = $part_export->export_insert($svc_x);
4415       if ( $error ) {
4416         $dbh->rollback if $oldAutoCommit;
4417         return $error;
4418       }
4419     }
4420   }
4421
4422   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4423   '';
4424
4425 }
4426
4427 =item export_pkg_change OLD_CUST_PKG
4428
4429 Calls the "pkg_change" export action for all services attached to this package.
4430
4431 =cut
4432
4433 sub export_pkg_change {
4434   my( $self, $old )  = ( shift, shift );
4435
4436   my $oldAutoCommit = $FS::UID::AutoCommit;
4437   local $FS::UID::AutoCommit = 0;
4438   my $dbh = dbh;
4439
4440   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
4441     my $error = $svc_x->export('pkg_change', $self, $old);
4442     if ( $error ) {
4443       $dbh->rollback if $oldAutoCommit;
4444       return $error;
4445     }
4446   }
4447
4448   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4449   '';
4450
4451 }
4452
4453 =item insert_reason
4454
4455 Associates this package with a (suspension or cancellation) reason (see
4456 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
4457 L<FS::reason>).
4458
4459 Available options are:
4460
4461 =over 4
4462
4463 =item reason
4464
4465 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.
4466
4467 =item reason_otaker
4468
4469 the access_user (see L<FS::access_user>) providing the reason
4470
4471 =item date
4472
4473 a unix timestamp 
4474
4475 =item action
4476
4477 the action (cancel, susp, adjourn, expire) associated with the reason
4478
4479 =back
4480
4481 If there is an error, returns the error, otherwise returns false.
4482
4483 =cut
4484
4485 sub insert_reason {
4486   my ($self, %options) = @_;
4487
4488   my $otaker = $options{reason_otaker} ||
4489                $FS::CurrentUser::CurrentUser->username;
4490
4491   my $reasonnum;
4492   if ( $options{'reason'} =~ /^(\d+)$/ ) {
4493
4494     $reasonnum = $1;
4495
4496   } elsif ( ref($options{'reason'}) ) {
4497   
4498     return 'Enter a new reason (or select an existing one)'
4499       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
4500
4501     my $reason = new FS::reason({
4502       'reason_type' => $options{'reason'}->{'typenum'},
4503       'reason'      => $options{'reason'}->{'reason'},
4504     });
4505     my $error = $reason->insert;
4506     return $error if $error;
4507
4508     $reasonnum = $reason->reasonnum;
4509
4510   } else {
4511     return "Unparseable reason: ". $options{'reason'};
4512   }
4513
4514   my $cust_pkg_reason =
4515     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
4516                               'reasonnum' => $reasonnum, 
4517                               'otaker'    => $otaker,
4518                               'action'    => substr(uc($options{'action'}),0,1),
4519                               'date'      => $options{'date'}
4520                                                ? $options{'date'}
4521                                                : time,
4522                             });
4523
4524   $cust_pkg_reason->insert;
4525 }
4526
4527 =item insert_discount
4528
4529 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
4530 inserting a new discount on the fly (see L<FS::discount>).
4531
4532 This will look at the cust_pkg for a pseudo-field named "setup_discountnum",
4533 and if present, will create a setup discount. If the discountnum is -1,
4534 a new discount definition will be inserted using the value in
4535 "setup_discountnum_amount" or "setup_discountnum_percent". Likewise for recur.
4536
4537 If there is an error, returns the error, otherwise returns false.
4538
4539 =cut
4540
4541 sub insert_discount {
4542   #my ($self, %options) = @_;
4543   my $self = shift;
4544
4545   foreach my $x (qw(setup recur)) {
4546     if ( my $discountnum = $self->get("${x}_discountnum") ) {
4547       my $cust_pkg_discount = FS::cust_pkg_discount->new( {
4548         'pkgnum'      => $self->pkgnum,
4549         'discountnum' => $discountnum,
4550         'setuprecur'  => $x,
4551         'months_used' => 0,
4552         'end_date'    => '', #XXX
4553         #for the create a new discount case
4554         'amount'      => $self->get("${x}_discountnum_amount"),
4555         'percent'     => $self->get("${x}_discountnum_percent"),
4556         'months'      => $self->get("${x}_discountnum_months"),
4557       } );
4558       if ( $x eq 'setup' ) {
4559         $cust_pkg_discount->setup('Y');
4560         $cust_pkg_discount->months('');
4561       }
4562       my $error = $cust_pkg_discount->insert;
4563       return $error if $error;
4564     }
4565   }
4566
4567   '';
4568 }
4569
4570 =item set_usage USAGE_VALUE_HASHREF 
4571
4572 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4573 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4574 upbytes, downbytes, and totalbytes are appropriate keys.
4575
4576 All svc_accts which are part of this package have their values reset.
4577
4578 =cut
4579
4580 sub set_usage {
4581   my ($self, $valueref, %opt) = @_;
4582
4583   #only svc_acct can set_usage for now
4584   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4585     my $svc_x = $cust_svc->svc_x;
4586     $svc_x->set_usage($valueref, %opt)
4587       if $svc_x->can("set_usage");
4588   }
4589 }
4590
4591 =item recharge USAGE_VALUE_HASHREF 
4592
4593 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4594 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4595 upbytes, downbytes, and totalbytes are appropriate keys.
4596
4597 All svc_accts which are part of this package have their values incremented.
4598
4599 =cut
4600
4601 sub recharge {
4602   my ($self, $valueref) = @_;
4603
4604   #only svc_acct can set_usage for now
4605   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4606     my $svc_x = $cust_svc->svc_x;
4607     $svc_x->recharge($valueref)
4608       if $svc_x->can("recharge");
4609   }
4610 }
4611
4612 =item apply_usageprice 
4613
4614 =cut
4615
4616 sub apply_usageprice {
4617   my $self = shift;
4618
4619   my $oldAutoCommit = $FS::UID::AutoCommit;
4620   local $FS::UID::AutoCommit = 0;
4621   my $dbh = dbh;
4622
4623   my $error = '';
4624
4625   foreach my $cust_pkg_usageprice ( $self->cust_pkg_usageprice ) {
4626     $error ||= $cust_pkg_usageprice->apply;
4627   }
4628
4629   if ( $error ) {
4630     $dbh->rollback if $oldAutoCommit;
4631     die "error applying part_pkg_usageprice add-ons, pkgnum ". $self->pkgnum.
4632         ": $error\n";
4633   } else {
4634     $dbh->commit if $oldAutoCommit;
4635   }
4636
4637
4638 }
4639
4640 =item cust_pkg_discount
4641
4642 =item cust_pkg_discount_active
4643
4644 =cut
4645
4646 sub cust_pkg_discount_active {
4647   my $self = shift;
4648   grep { $_->status eq 'active' } $self->cust_pkg_discount;
4649 }
4650
4651 =item cust_pkg_usage
4652
4653 Returns a list of all voice usage counters attached to this package.
4654
4655 =item apply_usage OPTIONS
4656
4657 Takes the following options:
4658 - cdr: a call detail record (L<FS::cdr>)
4659 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
4660 - minutes: the maximum number of minutes to be charged
4661
4662 Finds available usage minutes for a call of this class, and subtracts
4663 up to that many minutes from the usage pool.  If the usage pool is empty,
4664 and the C<cdr-minutes_priority> global config option is set, minutes may
4665 be taken from other calls as well.  Either way, an allocation record will
4666 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
4667 number of minutes of usage applied to the call.
4668
4669 =cut
4670
4671 sub apply_usage {
4672   my ($self, %opt) = @_;
4673   my $cdr = $opt{cdr};
4674   my $rate_detail = $opt{rate_detail};
4675   my $minutes = $opt{minutes};
4676   my $classnum = $rate_detail->classnum;
4677   my $pkgnum = $self->pkgnum;
4678   my $custnum = $self->custnum;
4679
4680   my $oldAutoCommit = $FS::UID::AutoCommit;
4681   local $FS::UID::AutoCommit = 0;
4682   my $dbh = dbh;
4683
4684   my $order = FS::Conf->new->config('cdr-minutes_priority');
4685
4686   my $is_classnum;
4687   if ( $classnum ) {
4688     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
4689   } else {
4690     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
4691   }
4692   my @usage_recs = qsearch({
4693       'table'     => 'cust_pkg_usage',
4694       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
4695                      ' JOIN cust_pkg             USING (pkgnum)'.
4696                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
4697       'select'    => 'cust_pkg_usage.*',
4698       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
4699                      " ( cust_pkg.custnum = $custnum AND ".
4700                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4701                      $is_classnum . ' AND '.
4702                      " cust_pkg_usage.minutes > 0",
4703       'order_by'  => " ORDER BY priority ASC",
4704   });
4705
4706   my $orig_minutes = $minutes;
4707   my $error;
4708   while (!$error and $minutes > 0 and @usage_recs) {
4709     my $cust_pkg_usage = shift @usage_recs;
4710     $cust_pkg_usage->select_for_update;
4711     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
4712         pkgusagenum => $cust_pkg_usage->pkgusagenum,
4713         acctid      => $cdr->acctid,
4714         minutes     => min($cust_pkg_usage->minutes, $minutes),
4715     });
4716     $cust_pkg_usage->set('minutes',
4717       $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes
4718     );
4719     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
4720     $minutes -= $cdr_cust_pkg_usage->minutes;
4721   }
4722   if ( $order and $minutes > 0 and !$error ) {
4723     # then try to steal minutes from another call
4724     my %search = (
4725         'table'     => 'cdr_cust_pkg_usage',
4726         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
4727                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
4728                        ' JOIN cust_pkg              USING (pkgnum)'.
4729                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
4730                        ' JOIN cdr                   USING (acctid)',
4731         'select'    => 'cdr_cust_pkg_usage.*',
4732         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
4733                        " ( cust_pkg.pkgnum = $pkgnum OR ".
4734                        " ( cust_pkg.custnum = $custnum AND ".
4735                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4736                        " part_pkg_usage_class.classnum = $classnum",
4737         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
4738     );
4739     if ( $order eq 'time' ) {
4740       # find CDRs that are using minutes, but have a later startdate
4741       # than this call
4742       my $startdate = $cdr->startdate;
4743       if ($startdate !~ /^\d+$/) {
4744         die "bad cdr startdate '$startdate'";
4745       }
4746       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
4747       # minimize needless reshuffling
4748       $search{'order_by'} .= ', cdr.startdate DESC';
4749     } else {
4750       # XXX may not work correctly with rate_time schedules.  Could 
4751       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
4752       # think...
4753       $search{'addl_from'} .=
4754         ' JOIN rate_detail'.
4755         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4756       if ( $order eq 'rate_high' ) {
4757         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4758                                 $rate_detail->min_charge;
4759         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4760       } elsif ( $order eq 'rate_low' ) {
4761         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4762                                 $rate_detail->min_charge;
4763         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4764       } else {
4765         #  this should really never happen
4766         die "invalid cdr-minutes_priority value '$order'\n";
4767       }
4768     }
4769     my @cdr_usage_recs = qsearch(\%search);
4770     my %reproc_cdrs;
4771     while (!$error and @cdr_usage_recs and $minutes > 0) {
4772       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4773       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4774       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4775       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4776       $cdr_cust_pkg_usage->select_for_update;
4777       $old_cdr->select_for_update;
4778       $cust_pkg_usage->select_for_update;
4779       # in case someone else stole the usage from this CDR
4780       # while waiting for the lock...
4781       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4782       # steal the usage allocation and flag the old CDR for reprocessing
4783       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4784       # if the allocation is more minutes than we need, adjust it...
4785       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4786       if ( $delta > 0 ) {
4787         $cdr_cust_pkg_usage->set('minutes', $minutes);
4788         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4789         $error = $cust_pkg_usage->replace;
4790       }
4791       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4792       $error ||= $cdr_cust_pkg_usage->replace;
4793       # deduct the stolen minutes
4794       $minutes -= $cdr_cust_pkg_usage->minutes;
4795     }
4796     # after all minute-stealing is done, reset the affected CDRs
4797     foreach (values %reproc_cdrs) {
4798       $error ||= $_->set_status('');
4799       # XXX or should we just call $cdr->rate right here?
4800       # it's not like we can create a loop this way, since the min_charge
4801       # or call time has to go monotonically in one direction.
4802       # we COULD get some very deep recursions going, though...
4803     }
4804   } # if $order and $minutes
4805   if ( $error ) {
4806     $dbh->rollback;
4807     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4808   } else {
4809     $dbh->commit if $oldAutoCommit;
4810     return $orig_minutes - $minutes;
4811   }
4812 }
4813
4814 =item supplemental_pkgs
4815
4816 Returns a list of all packages supplemental to this one.
4817
4818 =cut
4819
4820 sub supplemental_pkgs {
4821   my $self = shift;
4822   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4823 }
4824
4825 =item main_pkg
4826
4827 Returns the package that this one is supplemental to, if any.
4828
4829 =cut
4830
4831 sub main_pkg {
4832   my $self = shift;
4833   if ( $self->main_pkgnum ) {
4834     return FS::cust_pkg->by_key($self->main_pkgnum);
4835   }
4836   return;
4837 }
4838
4839 =back
4840
4841 =head1 CLASS METHODS
4842
4843 =over 4
4844
4845 =item recurring_sql
4846
4847 Returns an SQL expression identifying recurring packages.
4848
4849 =cut
4850
4851 sub recurring_sql { "
4852   '0' != ( select freq from part_pkg
4853              where cust_pkg.pkgpart = part_pkg.pkgpart )
4854 "; }
4855
4856 =item onetime_sql
4857
4858 Returns an SQL expression identifying one-time packages.
4859
4860 =cut
4861
4862 sub onetime_sql { "
4863   '0' = ( select freq from part_pkg
4864             where cust_pkg.pkgpart = part_pkg.pkgpart )
4865 "; }
4866
4867 =item ordered_sql
4868
4869 Returns an SQL expression identifying ordered packages (recurring packages not
4870 yet billed).
4871
4872 =cut
4873
4874 sub ordered_sql {
4875    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4876 }
4877
4878 =item active_sql
4879
4880 Returns an SQL expression identifying active packages.
4881
4882 =cut
4883
4884 sub active_sql {
4885   $_[0]->recurring_sql. "
4886   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4887   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4888   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4889 "; }
4890
4891 =item not_yet_billed_sql
4892
4893 Returns an SQL expression identifying packages which have not yet been billed.
4894
4895 =cut
4896
4897 sub not_yet_billed_sql { "
4898       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4899   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4900   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4901 "; }
4902
4903 =item inactive_sql
4904
4905 Returns an SQL expression identifying inactive packages (one-time packages
4906 that are otherwise unsuspended/uncancelled).
4907
4908 =cut
4909
4910 sub inactive_sql { "
4911   ". $_[0]->onetime_sql(). "
4912   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4913   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4914   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4915 "; }
4916
4917 =item on_hold_sql
4918
4919 Returns an SQL expression identifying on-hold packages.
4920
4921 =cut
4922
4923 sub on_hold_sql {
4924   #$_[0]->recurring_sql(). ' AND '.
4925   "
4926         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel  = 0 )
4927     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp   != 0
4928     AND ( cust_pkg.setup  IS     NULL  OR cust_pkg.setup   = 0 )
4929   ";
4930 }
4931
4932 =item susp_sql
4933 =item suspended_sql
4934
4935 Returns an SQL expression identifying suspended packages.
4936
4937 =cut
4938
4939 sub suspended_sql { susp_sql(@_); }
4940 sub susp_sql {
4941   #$_[0]->recurring_sql(). ' AND '.
4942   "
4943         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4944     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4945     AND   cust_pkg.setup  IS NOT NULL AND cust_pkg.setup != 0
4946   ";
4947 }
4948
4949 =item cancel_sql
4950 =item cancelled_sql
4951
4952 Returns an SQL exprression identifying cancelled packages.
4953
4954 =cut
4955
4956 sub cancelled_sql { cancel_sql(@_); }
4957 sub cancel_sql { 
4958   #$_[0]->recurring_sql(). ' AND '.
4959   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4960 }
4961
4962 =item status_sql
4963
4964 Returns an SQL expression to give the package status as a string.
4965
4966 =cut
4967
4968 sub status_sql {
4969 "CASE
4970   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4971   WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold'
4972   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4973   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4974   WHEN ".onetime_sql()." THEN 'one-time charge'
4975   ELSE 'active'
4976 END"
4977 }
4978
4979 =item fcc_477_count
4980
4981 Returns a list of two package counts.  The first is a count of packages
4982 based on the supplied criteria and the second is the count of residential
4983 packages with those same criteria.  Criteria are specified as in the search
4984 method.
4985
4986 =cut
4987
4988 sub fcc_477_count {
4989   my ($class, $params) = @_;
4990
4991   my $sql_query = $class->search( $params );
4992
4993   my $count_sql = delete($sql_query->{'count_query'});
4994   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4995     or die "couldn't parse count_sql";
4996
4997   my $count_sth = dbh->prepare($count_sql)
4998     or die "Error preparing $count_sql: ". dbh->errstr;
4999   $count_sth->execute
5000     or die "Error executing $count_sql: ". $count_sth->errstr;
5001   my $count_arrayref = $count_sth->fetchrow_arrayref;
5002
5003   return ( @$count_arrayref );
5004
5005 }
5006
5007 =item tax_locationnum_sql
5008
5009 Returns an SQL expression for the tax location for a package, based
5010 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
5011
5012 =cut
5013
5014 sub tax_locationnum_sql {
5015   my $conf = FS::Conf->new;
5016   if ( $conf->exists('tax-pkg_address') ) {
5017     'cust_pkg.locationnum';
5018   }
5019   elsif ( $conf->exists('tax-ship_address') ) {
5020     'cust_main.ship_locationnum';
5021   }
5022   else {
5023     'cust_main.bill_locationnum';
5024   }
5025 }
5026
5027 =item location_sql
5028
5029 Returns a list: the first item is an SQL fragment identifying matching 
5030 packages/customers via location (taking into account shipping and package
5031 address taxation, if enabled), and subsequent items are the parameters to
5032 substitute for the placeholders in that fragment.
5033
5034 =cut
5035
5036 sub location_sql {
5037   my($class, %opt) = @_;
5038   my $ornull = $opt{'ornull'};
5039
5040   my $conf = new FS::Conf;
5041
5042   # '?' placeholders in _location_sql_where
5043   my $x = $ornull ? 3 : 2;
5044   my @bill_param = ( 
5045     ('district')x3,
5046     ('city')x3, 
5047     ('county')x$x,
5048     ('state')x$x,
5049     'country'
5050   );
5051
5052   my $main_where;
5053   my @main_param;
5054   if ( $conf->exists('tax-ship_address') ) {
5055
5056     $main_where = "(
5057          (     ( ship_last IS NULL     OR  ship_last  = '' )
5058            AND ". _location_sql_where('cust_main', '', $ornull ). "
5059          )
5060       OR (       ship_last IS NOT NULL AND ship_last != ''
5061            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
5062          )
5063     )";
5064     #    AND payby != 'COMP'
5065
5066     @main_param = ( @bill_param, @bill_param );
5067
5068   } else {
5069
5070     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
5071     @main_param = @bill_param;
5072
5073   }
5074
5075   my $where;
5076   my @param;
5077   if ( $conf->exists('tax-pkg_address') ) {
5078
5079     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
5080
5081     $where = " (
5082                     ( cust_pkg.locationnum IS     NULL AND $main_where )
5083                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
5084                )
5085              ";
5086     @param = ( @main_param, @bill_param );
5087   
5088   } else {
5089
5090     $where = $main_where;
5091     @param = @main_param;
5092
5093   }
5094
5095   ( $where, @param );
5096
5097 }
5098
5099 #subroutine, helper for location_sql
5100 sub _location_sql_where {
5101   my $table  = shift;
5102   my $prefix = @_ ? shift : '';
5103   my $ornull = @_ ? shift : '';
5104
5105 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
5106
5107   $ornull = $ornull ? ' OR ? IS NULL ' : '';
5108
5109   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
5110   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
5111   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
5112
5113   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
5114
5115 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
5116   "
5117         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
5118     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
5119     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
5120     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
5121     AND   $table.${prefix}country  = ?
5122   ";
5123 }
5124
5125 sub _X_show_zero {
5126   my( $self, $what ) = @_;
5127
5128   my $what_show_zero = $what. '_show_zero';
5129   length($self->$what_show_zero())
5130     ? ($self->$what_show_zero() eq 'Y')
5131     : $self->part_pkg->$what_show_zero();
5132 }
5133
5134 =head1 SUBROUTINES
5135
5136 =over 4
5137
5138 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
5139
5140 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
5141 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
5142
5143 CUSTNUM is a customer (see L<FS::cust_main>)
5144
5145 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5146 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
5147 permitted.
5148
5149 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
5150 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
5151 new billing items.  An error is returned if this is not possible (see
5152 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
5153 parameter.
5154
5155 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5156 newly-created cust_pkg objects.
5157
5158 REFNUM, if specified, will specify the FS::pkg_referral record to be created
5159 and inserted.  Multiple FS::pkg_referral records can be created by
5160 setting I<refnum> to an array reference of refnums or a hash reference with
5161 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
5162 record will be created corresponding to cust_main.refnum.
5163
5164 =cut
5165
5166 sub order {
5167   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
5168
5169   my $conf = new FS::Conf;
5170
5171   # Transactionize this whole mess
5172   my $oldAutoCommit = $FS::UID::AutoCommit;
5173   local $FS::UID::AutoCommit = 0;
5174   my $dbh = dbh;
5175
5176   my $error;
5177 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
5178 #  return "Customer not found: $custnum" unless $cust_main;
5179
5180   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
5181     if $DEBUG;
5182
5183   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5184                          @$remove_pkgnum;
5185
5186   my $change = scalar(@old_cust_pkg) != 0;
5187
5188   my %hash = (); 
5189   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
5190
5191     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
5192          " to pkgpart ". $pkgparts->[0]. "\n"
5193       if $DEBUG;
5194
5195     my $err_or_cust_pkg =
5196       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
5197                                 'refnum'  => $refnum,
5198                               );
5199
5200     unless (ref($err_or_cust_pkg)) {
5201       $dbh->rollback if $oldAutoCommit;
5202       return $err_or_cust_pkg;
5203     }
5204
5205     push @$return_cust_pkg, $err_or_cust_pkg;
5206     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5207     return '';
5208
5209   }
5210
5211   # Create the new packages.
5212   foreach my $pkgpart (@$pkgparts) {
5213
5214     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
5215
5216     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
5217                                       pkgpart => $pkgpart,
5218                                       refnum  => $refnum,
5219                                       %hash,
5220                                     };
5221     $error = $cust_pkg->insert( 'change' => $change );
5222     push @$return_cust_pkg, $cust_pkg;
5223
5224     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
5225       my $supp_pkg = FS::cust_pkg->new({
5226           custnum => $custnum,
5227           pkgpart => $link->dst_pkgpart,
5228           refnum  => $refnum,
5229           main_pkgnum => $cust_pkg->pkgnum,
5230           %hash,
5231       });
5232       $error ||= $supp_pkg->insert( 'change' => $change );
5233       push @$return_cust_pkg, $supp_pkg;
5234     }
5235
5236     if ($error) {
5237       $dbh->rollback if $oldAutoCommit;
5238       return $error;
5239     }
5240
5241   }
5242   # $return_cust_pkg now contains refs to all of the newly 
5243   # created packages.
5244
5245   # Transfer services and cancel old packages.
5246   foreach my $old_pkg (@old_cust_pkg) {
5247
5248     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
5249       if $DEBUG;
5250
5251     foreach my $new_pkg (@$return_cust_pkg) {
5252       $error = $old_pkg->transfer($new_pkg);
5253       if ($error and $error == 0) {
5254         # $old_pkg->transfer failed.
5255         $dbh->rollback if $oldAutoCommit;
5256         return $error;
5257       }
5258     }
5259
5260     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
5261       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
5262       foreach my $new_pkg (@$return_cust_pkg) {
5263         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
5264         if ($error and $error == 0) {
5265           # $old_pkg->transfer failed.
5266         $dbh->rollback if $oldAutoCommit;
5267         return $error;
5268         }
5269       }
5270     }
5271
5272     if ($error > 0) {
5273       # Transfers were successful, but we went through all of the 
5274       # new packages and still had services left on the old package.
5275       # We can't cancel the package under the circumstances, so abort.
5276       $dbh->rollback if $oldAutoCommit;
5277       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
5278     }
5279     $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 );
5280     if ($error) {
5281       $dbh->rollback;
5282       return $error;
5283     }
5284   }
5285   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5286   '';
5287 }
5288
5289 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
5290
5291 A bulk change method to change packages for multiple customers.
5292
5293 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5294 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
5295 permitted.
5296
5297 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5298 replace.  The services (see L<FS::cust_svc>) are moved to the
5299 new billing items.  An error is returned if this is not possible (see
5300 L<FS::pkg_svc>).
5301
5302 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5303 newly-created cust_pkg objects.
5304
5305 =cut
5306
5307 sub bulk_change {
5308   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5309
5310   # Transactionize this whole mess
5311   my $oldAutoCommit = $FS::UID::AutoCommit;
5312   local $FS::UID::AutoCommit = 0;
5313   my $dbh = dbh;
5314
5315   my @errors;
5316   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5317                          @$remove_pkgnum;
5318
5319   while(scalar(@old_cust_pkg)) {
5320     my @return = ();
5321     my $custnum = $old_cust_pkg[0]->custnum;
5322     my (@remove) = map { $_->pkgnum }
5323                    grep { $_->custnum == $custnum } @old_cust_pkg;
5324     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5325
5326     my $error = order $custnum, $pkgparts, \@remove, \@return;
5327
5328     push @errors, $error
5329       if $error;
5330     push @$return_cust_pkg, @return;
5331   }
5332
5333   if (scalar(@errors)) {
5334     $dbh->rollback if $oldAutoCommit;
5335     return join(' / ', @errors);
5336   }
5337
5338   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5339   '';
5340 }
5341
5342 =item forward_emails
5343
5344 Returns a hash of svcnums and corresponding email addresses
5345 for svc_acct services that can be used as source or dest
5346 for svc_forward services provisioned in this package.
5347
5348 Accepts options I<svc_forward> OR I<svcnum> for a svc_forward
5349 service;  if included, will ensure the current values of the
5350 specified service are included in the list, even if for some
5351 other reason they wouldn't be.  If called as a class method
5352 with a specified service, returns only these current values.
5353
5354 Caution: does not actually check if svc_forward services are
5355 available to be provisioned on this package.
5356
5357 =cut
5358
5359 sub forward_emails {
5360   my $self = shift;
5361   my %opt = @_;
5362
5363   #load optional service, thoroughly validated
5364   die "Use svcnum or svc_forward, not both"
5365     if $opt{'svcnum'} && $opt{'svc_forward'};
5366   my $svc_forward = $opt{'svc_forward'};
5367   $svc_forward ||= qsearchs('svc_forward',{ 'svcnum' => $opt{'svcnum'} })
5368     if $opt{'svcnum'};
5369   die "Specified service is not a forward service"
5370     if $svc_forward && (ref($svc_forward) ne 'FS::svc_forward');
5371   die "Specified service not found"
5372     if ($opt{'svcnum'} || $opt{'svc_forward'}) && !$svc_forward;
5373
5374   my %email;
5375
5376   ## everything below was basically copied from httemplate/edit/svc_forward.cgi 
5377   ## with minimal refactoring, not sure why we can't just load all svc_accts for this custnum
5378
5379   #add current values from specified service, if there was one
5380   if ($svc_forward) {
5381     foreach my $method (qw( srcsvc_acct dstsvc_acct )) {
5382       my $svc_acct = $svc_forward->$method();
5383       $email{$svc_acct->svcnum} = $svc_acct->email if $svc_acct;
5384     }
5385   }
5386
5387   if (ref($self) eq 'FS::cust_pkg') {
5388
5389     #and including the rest for this customer
5390     my($u_part_svc,@u_acct_svcparts);
5391     foreach $u_part_svc ( qsearch('part_svc',{'svcdb'=>'svc_acct'}) ) {
5392       push @u_acct_svcparts,$u_part_svc->getfield('svcpart');
5393     }
5394
5395     my $custnum = $self->getfield('custnum');
5396     foreach my $i_cust_pkg ( qsearch('cust_pkg',{'custnum'=>$custnum}) ) {
5397       my $cust_pkgnum = $i_cust_pkg->getfield('pkgnum');
5398       #now find the corresponding record(s) in cust_svc (for this pkgnum!)
5399       foreach my $acct_svcpart (@u_acct_svcparts) {
5400         foreach my $i_cust_svc (
5401           qsearch( 'cust_svc', { 'pkgnum'  => $cust_pkgnum,
5402                                  'svcpart' => $acct_svcpart } )
5403         ) {
5404           my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $i_cust_svc->svcnum } );
5405           $email{$svc_acct->svcnum} = $svc_acct->email;
5406         }  
5407       }
5408     }
5409   }
5410
5411   return %email;
5412 }
5413
5414 # Used by FS::Upgrade to migrate to a new database.
5415 sub _upgrade_data {  # class method
5416   my ($class, %opts) = @_;
5417   $class->_upgrade_otaker(%opts);
5418   my @statements = (
5419     # RT#10139, bug resulting in contract_end being set when it shouldn't
5420   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5421     # RT#10830, bad calculation of prorate date near end of year
5422     # the date range for bill is December 2009, and we move it forward
5423     # one year if it's before the previous bill date (which it should 
5424     # never be)
5425   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5426   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5427   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5428     # RT6628, add order_date to cust_pkg
5429     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5430         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5431         history_action = \'insert\') where order_date is null',
5432   );
5433   foreach my $sql (@statements) {
5434     my $sth = dbh->prepare($sql);
5435     $sth->execute or die $sth->errstr;
5436   }
5437
5438   # RT31194: supplemental package links that are deleted don't clean up 
5439   # linked records
5440   my @pkglinknums = qsearch({
5441       'select'    => 'DISTINCT cust_pkg.pkglinknum',
5442       'table'     => 'cust_pkg',
5443       'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ',
5444       'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL 
5445                         AND part_pkg_link.pkglinknum IS NULL',
5446   });
5447   foreach (@pkglinknums) {
5448     my $pkglinknum = $_->pkglinknum;
5449     warn "cleaning part_pkg_link #$pkglinknum\n";
5450     my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum});
5451     my $error = $part_pkg_link->remove_linked;
5452     die $error if $error;
5453   }
5454 }
5455
5456 =back
5457
5458 =head1 BUGS
5459
5460 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5461
5462 In sub order, the @pkgparts array (passed by reference) is clobbered.
5463
5464 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5465 method to pass dates to the recur_prog expression, it should do so.
5466
5467 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5468 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5469 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5470 configuration values.  Probably need a subroutine which decides what to do
5471 based on whether or not we've fetched the user yet, rather than a hash.  See
5472 FS::UID and the TODO.
5473
5474 Now that things are transactional should the check in the insert method be
5475 moved to check ?
5476
5477 =head1 SEE ALSO
5478
5479 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5480 L<FS::pkg_svc>, schema.html from the base documentation
5481
5482 =cut
5483
5484 1;
5485