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