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