85234cfec5591dfb68055436626a3291ce05741c
[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     'table'   => 'cust_svc',
3265     'hashref' => { 'pkgnum' => $self->pkgnum },
3266   );
3267   if ( $opt{svcpart} ) {
3268     $search{hashref}->{svcpart} = $opt{'svcpart'};
3269   }
3270   if ( $opt{'svcdb'} ) {
3271     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
3272     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
3273   }
3274
3275   [ qsearch(\%search) ];
3276
3277 }
3278
3279 =item overlimit [ SVCPART ]
3280
3281 Returns the services for this package which have exceeded their
3282 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
3283 is specified, return only the matching services.
3284
3285 =cut
3286
3287 sub overlimit {
3288   my $self = shift;
3289   return () unless $self->num_cust_svc(@_);
3290   grep { $_->overlimit } $self->cust_svc(@_);
3291 }
3292
3293 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3294
3295 Returns historical services for this package created before END TIMESTAMP and
3296 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
3297 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
3298 I<pkg_svc.hidden> flag will be omitted.
3299
3300 =cut
3301
3302 sub h_cust_svc {
3303   my $self = shift;
3304   warn "$me _h_cust_svc called on $self\n"
3305     if $DEBUG;
3306
3307   my ($end, $start, $mode) = @_;
3308
3309   local($FS::Record::qsearch_qualify_columns) = 0;
3310
3311   my @cust_svc = $self->_sort_cust_svc(
3312     [ qsearch( 'h_cust_svc',
3313       { 'pkgnum' => $self->pkgnum, },  
3314       FS::h_cust_svc->sql_h_search(@_),  
3315     ) ]
3316   );
3317
3318   if ( defined($mode) && $mode eq 'I' ) {
3319     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
3320     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
3321   } else {
3322     return @cust_svc;
3323   }
3324 }
3325
3326 sub _sort_cust_svc {
3327   my( $self, $arrayref ) = @_;
3328
3329   my $sort =
3330     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
3331
3332   my %pkg_svc = map { $_->svcpart => $_ }
3333                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
3334
3335   map  { $_->[0] }
3336   sort $sort
3337   map {
3338         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
3339         [ $_,
3340           $pkg_svc ? $pkg_svc->primary_svc : '',
3341           $pkg_svc ? $pkg_svc->quantity : 0,
3342         ];
3343       }
3344   @$arrayref;
3345
3346 }
3347
3348 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
3349
3350 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
3351
3352 Returns the number of services for this package.  Available options are svcpart
3353 and svcdb.  If either is spcififed, returns only the matching services.
3354
3355 =cut
3356
3357 sub num_cust_svc {
3358   my $self = shift;
3359
3360   return $self->{'_num_cust_svc'}
3361     if !scalar(@_)
3362        && exists($self->{'_num_cust_svc'})
3363        && $self->{'_num_cust_svc'} =~ /\d/;
3364
3365   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
3366     if $DEBUG > 2;
3367
3368   my %opt = ();
3369   if ( @_ && $_[0] =~ /^\d+/ ) {
3370     $opt{svcpart} = shift;
3371   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3372     %opt = %{ $_[0] };
3373   } elsif ( @_ ) {
3374     %opt = @_;
3375   }
3376
3377   my $select = 'SELECT COUNT(*) FROM cust_svc ';
3378   my $where = ' WHERE pkgnum = ? ';
3379   my @param = ($self->pkgnum);
3380
3381   if ( $opt{'svcpart'} ) {
3382     $where .= ' AND svcpart = ? ';
3383     push @param, $opt{'svcpart'};
3384   }
3385   if ( $opt{'svcdb'} ) {
3386     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
3387     $where .= ' AND svcdb = ? ';
3388     push @param, $opt{'svcdb'};
3389   }
3390
3391   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
3392   $sth->execute(@param) or die $sth->errstr;
3393   $sth->fetchrow_arrayref->[0];
3394 }
3395
3396 =item available_part_svc 
3397
3398 Returns a list of FS::part_svc objects representing services included in this
3399 package but not yet provisioned.  Each FS::part_svc object also has an extra
3400 field, I<num_avail>, which specifies the number of available services.
3401
3402 Accepts option I<provision_hold>;  if true, only returns part_svc for which the
3403 associated pkg_svc has the provision_hold flag set.
3404
3405 =cut
3406
3407 sub available_part_svc {
3408   my $self = shift;
3409   my %opt  = @_;
3410
3411   my $pkg_quantity = $self->quantity || 1;
3412
3413   grep { $_->num_avail > 0 }
3414   map {
3415     my $part_svc = $_->part_svc;
3416     $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
3417     $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
3418
3419     # more evil encapsulation breakage
3420     if ($part_svc->{'Hash'}{'num_avail'} > 0) {
3421       my @exports = $part_svc->part_export_did;
3422       $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
3423         }
3424
3425     $part_svc;
3426   }
3427   grep { $opt{'provision_hold'} ? $_->provision_hold : 1 }
3428   $self->part_pkg->pkg_svc;
3429 }
3430
3431 =item part_svc [ OPTION => VALUE ... ]
3432
3433 Returns a list of FS::part_svc objects representing provisioned and available
3434 services included in this package.  Each FS::part_svc object also has the
3435 following extra fields:
3436
3437 =over 4
3438
3439 =item num_cust_svc
3440
3441 (count)
3442
3443 =item num_avail
3444
3445 (quantity - count)
3446
3447 =item cust_pkg_svc
3448
3449 (services) - array reference containing the provisioned services, as cust_svc objects
3450
3451 =back
3452
3453 Accepts two options:
3454
3455 =over 4
3456
3457 =item summarize_size
3458
3459 If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc
3460 is this size or greater.
3461
3462 =item hide_discontinued
3463
3464 If true, will omit looking for services that are no longer avaialble in the
3465 package definition.
3466
3467 =back
3468
3469 =cut
3470
3471 #svcnum
3472 #label -> ($cust_svc->label)[1]
3473
3474 sub part_svc {
3475   my $self = shift;
3476   my %opt = @_;
3477
3478   my $pkg_quantity = $self->quantity || 1;
3479
3480   #XXX some sort of sort order besides numeric by svcpart...
3481   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
3482     my $pkg_svc = $_;
3483     my $part_svc = $pkg_svc->part_svc;
3484     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3485     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
3486     $part_svc->{'Hash'}{'num_avail'}    =
3487       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
3488     $part_svc->{'Hash'}{'cust_pkg_svc'} =
3489         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
3490       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
3491           && $num_cust_svc >= $opt{summarize_size};
3492     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
3493     $part_svc;
3494   } $self->part_pkg->pkg_svc;
3495
3496   unless ( $opt{hide_discontinued} ) {
3497     #extras
3498     push @part_svc, map {
3499       my $part_svc = $_;
3500       my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3501       $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
3502       $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
3503       $part_svc->{'Hash'}{'cust_pkg_svc'} =
3504         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
3505       $part_svc;
3506     } $self->extra_part_svc;
3507   }
3508
3509   @part_svc;
3510
3511 }
3512
3513 =item extra_part_svc
3514
3515 Returns a list of FS::part_svc objects corresponding to services in this
3516 package which are still provisioned but not (any longer) available in the
3517 package definition.
3518
3519 =cut
3520
3521 sub extra_part_svc {
3522   my $self = shift;
3523
3524   my $pkgnum  = $self->pkgnum;
3525   #my $pkgpart = $self->pkgpart;
3526
3527 #  qsearch( {
3528 #    'table'     => 'part_svc',
3529 #    'hashref'   => {},
3530 #    'extra_sql' =>
3531 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
3532 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
3533 #                       AND pkg_svc.pkgpart = ?
3534 #                       AND quantity > 0 
3535 #                 )
3536 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
3537 #                       LEFT JOIN cust_pkg USING ( pkgnum )
3538 #                     WHERE cust_svc.svcpart = part_svc.svcpart
3539 #                       AND pkgnum = ?
3540 #                 )",
3541 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
3542 #  } );
3543
3544 #seems to benchmark slightly faster... (or did?)
3545
3546   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
3547   my $pkgparts = join(',', @pkgparts);
3548
3549   qsearch( {
3550     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
3551     #MySQL doesn't grok DISINCT ON
3552     'select'      => 'DISTINCT part_svc.*',
3553     'table'       => 'part_svc',
3554     'addl_from'   =>
3555       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
3556                                AND pkg_svc.pkgpart IN ($pkgparts)
3557                                AND quantity > 0
3558                              )
3559        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
3560        LEFT JOIN cust_pkg USING ( pkgnum )
3561       ",
3562     'hashref'     => {},
3563     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
3564     'extra_param' => [ [$self->pkgnum=>'int'] ],
3565   } );
3566 }
3567
3568 =item status
3569
3570 Returns a short status string for this package, currently:
3571
3572 =over 4
3573
3574 =item on hold
3575
3576 =item not yet billed
3577
3578 =item one-time charge
3579
3580 =item active
3581
3582 =item suspended
3583
3584 =item cancelled
3585
3586 =back
3587
3588 =cut
3589
3590 sub status {
3591   my $self = shift;
3592
3593   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
3594
3595   return 'cancelled' if $self->get('cancel');
3596   return 'on hold' if $self->susp && ! $self->setup;
3597   return 'suspended' if $self->susp;
3598   return 'not yet billed' unless $self->setup;
3599   return 'one-time charge' if $freq =~ /^(0|$)/;
3600   return 'active';
3601 }
3602
3603 =item ucfirst_status
3604
3605 Returns the status with the first character capitalized.
3606
3607 =cut
3608
3609 sub ucfirst_status {
3610   ucfirst(shift->status);
3611 }
3612
3613 =item statuses
3614
3615 Class method that returns the list of possible status strings for packages
3616 (see L<the status method|/status>).  For example:
3617
3618   @statuses = FS::cust_pkg->statuses();
3619
3620 =cut
3621
3622 tie my %statuscolor, 'Tie::IxHash', 
3623   'on hold'         => 'FF00F5', #brighter purple!
3624   'not yet billed'  => '009999', #teal? cyan?
3625   'one-time charge' => '0000CC', #blue  #'000000',
3626   'active'          => '00CC00',
3627   'suspended'       => 'FF9900',
3628   'cancelled'       => 'FF0000',
3629 ;
3630
3631 sub statuses {
3632   my $self = shift; #could be class...
3633   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
3634   #                                    # mayble split btw one-time vs. recur
3635     keys %statuscolor;
3636 }
3637
3638 sub statuscolors {
3639   #my $self = shift;
3640   \%statuscolor;
3641 }
3642
3643 =item statuscolor
3644
3645 Returns a hex triplet color string for this package's status.
3646
3647 =cut
3648
3649 sub statuscolor {
3650   my $self = shift;
3651   $statuscolor{$self->status};
3652 }
3653
3654 =item is_status_delay_cancel
3655
3656 Returns true if part_pkg has option delay_cancel, 
3657 cust_pkg status is 'suspended' and expire is set
3658 to cancel package within the next day (or however
3659 many days are set in global config part_pkg-delay_cancel-days.
3660
3661 Accepts option I<part_pkg-delay_cancel-days> which should be
3662 the value of the config setting, to avoid looking it up again.
3663
3664 This is not a real status, this only meant for hacking display 
3665 values, because otherwise treating the package as suspended is 
3666 really the whole point of the delay_cancel option.
3667
3668 =cut
3669
3670 sub is_status_delay_cancel {
3671   my ($self,%opt) = @_;
3672   if ( $self->main_pkgnum and $self->pkglinknum ) {
3673     return $self->main_pkg->is_status_delay_cancel;
3674   }
3675   return 0 unless $self->part_pkg->option('delay_cancel',1);
3676   return 0 unless $self->status eq 'suspended';
3677   return 0 unless $self->expire;
3678   my $expdays = $opt{'part_pkg-delay_cancel-days'};
3679   unless ($expdays) {
3680     my $conf = new FS::Conf;
3681     $expdays = $conf->config('part_pkg-delay_cancel-days') || 1;
3682   }
3683   my $expsecs = 60*60*24*$expdays;
3684   return 0 unless $self->expire < time + $expsecs;
3685   return 1;
3686 }
3687
3688 =item pkg_label
3689
3690 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3691 "pkg - comment" depending on user preference).
3692
3693 =cut
3694
3695 sub pkg_label {
3696   my $self = shift;
3697   my $label = $self->part_pkg->pkg_comment( cust_pkg=>$self, nopkgpart=>1 );
3698   $label = $self->pkgnum. ": $label"
3699     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3700   $label;
3701 }
3702
3703 =item pkg_label_long
3704
3705 Returns a long label for this package, adding the primary service's label to
3706 pkg_label.
3707
3708 =cut
3709
3710 sub pkg_label_long {
3711   my $self = shift;
3712   my $label = $self->pkg_label;
3713   my $cust_svc = $self->primary_cust_svc;
3714   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3715   $label;
3716 }
3717
3718 =item pkg_locale
3719
3720 Returns a customer-localized label for this package.
3721
3722 =cut
3723
3724 sub pkg_locale {
3725   my $self = shift;
3726   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3727 }
3728
3729 =item primary_cust_svc
3730
3731 Returns a primary service (as FS::cust_svc object) if one can be identified.
3732
3733 =cut
3734
3735 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3736
3737 sub primary_cust_svc {
3738   my $self = shift;
3739
3740   my @cust_svc = $self->cust_svc;
3741
3742   return '' unless @cust_svc; #no serivces - irrelevant then
3743   
3744   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3745
3746   # primary service as specified in the package definition
3747   # or exactly one service definition with quantity one
3748   my $svcpart = $self->part_pkg->svcpart;
3749   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3750   return $cust_svc[0] if scalar(@cust_svc) == 1;
3751
3752   #couldn't identify one thing..
3753   return '';
3754 }
3755
3756 =item labels
3757
3758 Returns a list of lists, calling the label method for all services
3759 (see L<FS::cust_svc>) of this billing item.
3760
3761 =cut
3762
3763 sub labels {
3764   my $self = shift;
3765   map { [ $_->label ] } $self->cust_svc;
3766 }
3767
3768 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3769
3770 Like the labels method, but returns historical information on services that
3771 were active as of END_TIMESTAMP and (optionally) not cancelled before
3772 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3773 I<pkg_svc.hidden> flag will be omitted.
3774
3775 Returns a list of lists, calling the label method for all (historical) services
3776 (see L<FS::h_cust_svc>) of this billing item.
3777
3778 =cut
3779
3780 sub h_labels {
3781   my $self = shift;
3782   warn "$me _h_labels called on $self\n"
3783     if $DEBUG;
3784   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3785 }
3786
3787 =item labels_short
3788
3789 Like labels, except returns a simple flat list, and shortens long
3790 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3791 identical services to one line that lists the service label and the number of
3792 individual services rather than individual items.
3793
3794 =cut
3795
3796 sub labels_short {
3797   shift->_labels_short( 'labels', @_ );
3798 }
3799
3800 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3801
3802 Like h_labels, except returns a simple flat list, and shortens long
3803 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3804 identical services to one line that lists the service label and the number of
3805 individual services rather than individual items.
3806
3807 =cut
3808
3809 sub h_labels_short {
3810   shift->_labels_short( 'h_labels', @_ );
3811 }
3812
3813 sub _labels_short {
3814   my( $self, $method ) = ( shift, shift );
3815
3816   warn "$me _labels_short called on $self with $method method\n"
3817     if $DEBUG;
3818
3819   my $conf = new FS::Conf;
3820   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3821
3822   warn "$me _labels_short populating \%labels\n"
3823     if $DEBUG;
3824
3825   my %labels;
3826   #tie %labels, 'Tie::IxHash';
3827   push @{ $labels{$_->[0]} }, $_->[1]
3828     foreach $self->$method(@_);
3829
3830   warn "$me _labels_short populating \@labels\n"
3831     if $DEBUG;
3832
3833   my @labels;
3834   foreach my $label ( keys %labels ) {
3835     my %seen = ();
3836     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3837     my $num = scalar(@values);
3838     warn "$me _labels_short $num items for $label\n"
3839       if $DEBUG;
3840
3841     if ( $num > $max_same_services ) {
3842       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3843         if $DEBUG;
3844       push @labels, "$label ($num)";
3845     } else {
3846       if ( $conf->exists('cust_bill-consolidate_services') ) {
3847         warn "$me _labels_short   consolidating services\n"
3848           if $DEBUG;
3849         # push @labels, "$label: ". join(', ', @values);
3850         while ( @values ) {
3851           my $detail = "$label: ";
3852           $detail .= shift(@values). ', '
3853             while @values
3854                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3855           $detail =~ s/, $//;
3856           push @labels, $detail;
3857         }
3858         warn "$me _labels_short   done consolidating services\n"
3859           if $DEBUG;
3860       } else {
3861         warn "$me _labels_short   adding service data\n"
3862           if $DEBUG;
3863         push @labels, map { "$label: $_" } @values;
3864       }
3865     }
3866   }
3867
3868  @labels;
3869
3870 }
3871
3872 =item cust_main
3873
3874 Returns the parent customer object (see L<FS::cust_main>).
3875
3876 =item balance
3877
3878 Returns the balance for this specific package, when using
3879 experimental package balance.
3880
3881 =cut
3882
3883 sub balance {
3884   my $self = shift;
3885   $self->cust_main->balance_pkgnum( $self->pkgnum );
3886 }
3887
3888 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3889
3890 =item cust_location
3891
3892 Returns the location object, if any (see L<FS::cust_location>).
3893
3894 =item cust_location_or_main
3895
3896 If this package is associated with a location, returns the locaiton (see
3897 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3898
3899 =item location_label [ OPTION => VALUE ... ]
3900
3901 Returns the label of the location object (see L<FS::cust_location>).
3902
3903 =cut
3904
3905 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3906
3907 =item tax_locationnum
3908
3909 Returns the foreign key to a L<FS::cust_location> object for calculating  
3910 tax on this package, as determined by the C<tax-pkg_address> and 
3911 C<tax-ship_address> configuration flags.
3912
3913 =cut
3914
3915 sub tax_locationnum {
3916   my $self = shift;
3917   my $conf = FS::Conf->new;
3918   if ( $conf->exists('tax-pkg_address') ) {
3919     return $self->locationnum;
3920   }
3921   elsif ( $conf->exists('tax-ship_address') ) {
3922     return $self->cust_main->ship_locationnum;
3923   }
3924   else {
3925     return $self->cust_main->bill_locationnum;
3926   }
3927 }
3928
3929 =item tax_location
3930
3931 Returns the L<FS::cust_location> object for tax_locationnum.
3932
3933 =cut
3934
3935 sub tax_location {
3936   my $self = shift;
3937   my $conf = FS::Conf->new;
3938   if ( $conf->exists('tax-pkg_address') and $self->locationnum ) {
3939     return FS::cust_location->by_key($self->locationnum);
3940   }
3941   elsif ( $conf->exists('tax-ship_address') ) {
3942     return $self->cust_main->ship_location;
3943   }
3944   else {
3945     return $self->cust_main->bill_location;
3946   }
3947 }
3948
3949 =item seconds_since TIMESTAMP
3950
3951 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3952 package have been online since TIMESTAMP, according to the session monitor.
3953
3954 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3955 L<Time::Local> and L<Date::Parse> for conversion functions.
3956
3957 =cut
3958
3959 sub seconds_since {
3960   my($self, $since) = @_;
3961   my $seconds = 0;
3962
3963   foreach my $cust_svc (
3964     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3965   ) {
3966     $seconds += $cust_svc->seconds_since($since);
3967   }
3968
3969   $seconds;
3970
3971 }
3972
3973 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3974
3975 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3976 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3977 (exclusive).
3978
3979 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3980 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3981 functions.
3982
3983
3984 =cut
3985
3986 sub seconds_since_sqlradacct {
3987   my($self, $start, $end) = @_;
3988
3989   my $seconds = 0;
3990
3991   foreach my $cust_svc (
3992     grep {
3993       my $part_svc = $_->part_svc;
3994       $part_svc->svcdb eq 'svc_acct'
3995         && scalar($part_svc->part_export_usage);
3996     } $self->cust_svc
3997   ) {
3998     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
3999   }
4000
4001   $seconds;
4002
4003 }
4004
4005 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
4006
4007 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
4008 in this package for sessions ending between TIMESTAMP_START (inclusive) and
4009 TIMESTAMP_END
4010 (exclusive).
4011
4012 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
4013 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
4014 functions.
4015
4016 =cut
4017
4018 sub attribute_since_sqlradacct {
4019   my($self, $start, $end, $attrib) = @_;
4020
4021   my $sum = 0;
4022
4023   foreach my $cust_svc (
4024     grep {
4025       my $part_svc = $_->part_svc;
4026       scalar($part_svc->part_export_usage);
4027     } $self->cust_svc
4028   ) {
4029     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
4030   }
4031
4032   $sum;
4033
4034 }
4035
4036 =item quantity
4037
4038 =cut
4039
4040 sub quantity {
4041   my( $self, $value ) = @_;
4042   if ( defined($value) ) {
4043     $self->setfield('quantity', $value);
4044   }
4045   $self->getfield('quantity') || 1;
4046 }
4047
4048 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
4049
4050 Transfers as many services as possible from this package to another package.
4051
4052 The destination package can be specified by pkgnum by passing an FS::cust_pkg
4053 object.  The destination package must already exist.
4054
4055 Services are moved only if the destination allows services with the correct
4056 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
4057 this option with caution!  No provision is made for export differences
4058 between the old and new service definitions.  Probably only should be used
4059 when your exports for all service definitions of a given svcdb are identical.
4060 (attempt a transfer without it first, to move all possible svcpart-matching
4061 services)
4062
4063 Any services that can't be moved remain in the original package.
4064
4065 Returns an error, if there is one; otherwise, returns the number of services 
4066 that couldn't be moved.
4067
4068 =cut
4069
4070 sub transfer {
4071   my ($self, $dest_pkgnum, %opt) = @_;
4072
4073   my $remaining = 0;
4074   my $dest;
4075   my %target;
4076
4077   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
4078     $dest = $dest_pkgnum;
4079     $dest_pkgnum = $dest->pkgnum;
4080   } else {
4081     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
4082   }
4083
4084   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
4085
4086   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
4087     $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 );
4088   }
4089
4090   foreach my $cust_svc ($dest->cust_svc) {
4091     $target{$cust_svc->svcpart}--;
4092   }
4093
4094   my %svcpart2svcparts = ();
4095   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4096     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
4097     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
4098       next if exists $svcpart2svcparts{$svcpart};
4099       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
4100       $svcpart2svcparts{$svcpart} = [
4101         map  { $_->[0] }
4102         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
4103         map {
4104               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
4105                                                    'svcpart' => $_          } );
4106               [ $_,
4107                 $pkg_svc ? $pkg_svc->primary_svc : '',
4108                 $pkg_svc ? $pkg_svc->quantity : 0,
4109               ];
4110             }
4111
4112         grep { $_ != $svcpart }
4113         map  { $_->svcpart }
4114         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
4115       ];
4116       warn "alternates for svcpart $svcpart: ".
4117            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
4118         if $DEBUG;
4119     }
4120   }
4121
4122   my $error;
4123   foreach my $cust_svc ($self->cust_svc) {
4124     my $svcnum = $cust_svc->svcnum;
4125     if($target{$cust_svc->svcpart} > 0
4126        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
4127       $target{$cust_svc->svcpart}--;
4128       my $new = new FS::cust_svc { $cust_svc->hash };
4129       $new->pkgnum($dest_pkgnum);
4130       $error = $new->replace($cust_svc);
4131     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4132       if ( $DEBUG ) {
4133         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
4134         warn "alternates to consider: ".
4135              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
4136       }
4137       my @alternate = grep {
4138                              warn "considering alternate svcpart $_: ".
4139                                   "$target{$_} available in new package\n"
4140                                if $DEBUG;
4141                              $target{$_} > 0;
4142                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
4143       if ( @alternate ) {
4144         warn "alternate(s) found\n" if $DEBUG;
4145         my $change_svcpart = $alternate[0];
4146         $target{$change_svcpart}--;
4147         my $new = new FS::cust_svc { $cust_svc->hash };
4148         $new->svcpart($change_svcpart);
4149         $new->pkgnum($dest_pkgnum);
4150         $error = $new->replace($cust_svc);
4151       } else {
4152         $remaining++;
4153       }
4154     } else {
4155       $remaining++
4156     }
4157     if ( $error ) {
4158       my @label = $cust_svc->label;
4159       return "$label[0] $label[1]: $error";
4160     }
4161   }
4162   return $remaining;
4163 }
4164
4165 =item grab_svcnums SVCNUM, SVCNUM ...
4166
4167 Change the pkgnum for the provided services to this packages.  If there is an
4168 error, returns the error, otherwise returns false.
4169
4170 =cut
4171
4172 sub grab_svcnums {
4173   my $self = shift;
4174   my @svcnum = @_;
4175
4176   my $oldAutoCommit = $FS::UID::AutoCommit;
4177   local $FS::UID::AutoCommit = 0;
4178   my $dbh = dbh;
4179
4180   foreach my $svcnum (@svcnum) {
4181     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
4182       $dbh->rollback if $oldAutoCommit;
4183       return "unknown svcnum $svcnum";
4184     };
4185     $cust_svc->pkgnum( $self->pkgnum );
4186     my $error = $cust_svc->replace;
4187     if ( $error ) {
4188       $dbh->rollback if $oldAutoCommit;
4189       return $error;
4190     }
4191   }
4192
4193   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4194   '';
4195
4196 }
4197
4198 =item reexport
4199
4200 This method is deprecated.  See the I<depend_jobnum> option to the insert and
4201 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
4202
4203 =cut
4204
4205 #looks like this is still used by the order_pkg and change_pkg methods in
4206 # ClientAPI/MyAccount, need to look into those before removing
4207 sub reexport {
4208   my $self = shift;
4209
4210   my $oldAutoCommit = $FS::UID::AutoCommit;
4211   local $FS::UID::AutoCommit = 0;
4212   my $dbh = dbh;
4213
4214   foreach my $cust_svc ( $self->cust_svc ) {
4215     #false laziness w/svc_Common::insert
4216     my $svc_x = $cust_svc->svc_x;
4217     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
4218       my $error = $part_export->export_insert($svc_x);
4219       if ( $error ) {
4220         $dbh->rollback if $oldAutoCommit;
4221         return $error;
4222       }
4223     }
4224   }
4225
4226   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4227   '';
4228
4229 }
4230
4231 =item export_pkg_change OLD_CUST_PKG
4232
4233 Calls the "pkg_change" export action for all services attached to this package.
4234
4235 =cut
4236
4237 sub export_pkg_change {
4238   my( $self, $old )  = ( shift, shift );
4239
4240   my $oldAutoCommit = $FS::UID::AutoCommit;
4241   local $FS::UID::AutoCommit = 0;
4242   my $dbh = dbh;
4243
4244   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
4245     my $error = $svc_x->export('pkg_change', $self, $old);
4246     if ( $error ) {
4247       $dbh->rollback if $oldAutoCommit;
4248       return $error;
4249     }
4250   }
4251
4252   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4253   '';
4254
4255 }
4256
4257 =item insert_reason
4258
4259 Associates this package with a (suspension or cancellation) reason (see
4260 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
4261 L<FS::reason>).
4262
4263 Available options are:
4264
4265 =over 4
4266
4267 =item reason
4268
4269 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.
4270
4271 =item reason_otaker
4272
4273 the access_user (see L<FS::access_user>) providing the reason
4274
4275 =item date
4276
4277 a unix timestamp 
4278
4279 =item action
4280
4281 the action (cancel, susp, adjourn, expire) associated with the reason
4282
4283 =back
4284
4285 If there is an error, returns the error, otherwise returns false.
4286
4287 =cut
4288
4289 sub insert_reason {
4290   my ($self, %options) = @_;
4291
4292   my $otaker = $options{reason_otaker} ||
4293                $FS::CurrentUser::CurrentUser->username;
4294
4295   my $reasonnum;
4296   if ( $options{'reason'} =~ /^(\d+)$/ ) {
4297
4298     $reasonnum = $1;
4299
4300   } elsif ( ref($options{'reason'}) ) {
4301   
4302     return 'Enter a new reason (or select an existing one)'
4303       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
4304
4305     my $reason = new FS::reason({
4306       'reason_type' => $options{'reason'}->{'typenum'},
4307       'reason'      => $options{'reason'}->{'reason'},
4308     });
4309     my $error = $reason->insert;
4310     return $error if $error;
4311
4312     $reasonnum = $reason->reasonnum;
4313
4314   } else {
4315     return "Unparseable reason: ". $options{'reason'};
4316   }
4317
4318   my $cust_pkg_reason =
4319     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
4320                               'reasonnum' => $reasonnum, 
4321                               'otaker'    => $otaker,
4322                               'action'    => substr(uc($options{'action'}),0,1),
4323                               'date'      => $options{'date'}
4324                                                ? $options{'date'}
4325                                                : time,
4326                             });
4327
4328   $cust_pkg_reason->insert;
4329 }
4330
4331 =item insert_discount
4332
4333 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
4334 inserting a new discount on the fly (see L<FS::discount>).
4335
4336 This will look at the cust_pkg for a pseudo-field named "setup_discountnum",
4337 and if present, will create a setup discount. If the discountnum is -1,
4338 a new discount definition will be inserted using the value in
4339 "setup_discountnum_amount" or "setup_discountnum_percent". Likewise for recur.
4340
4341 If there is an error, returns the error, otherwise returns false.
4342
4343 =cut
4344
4345 sub insert_discount {
4346   #my ($self, %options) = @_;
4347   my $self = shift;
4348
4349   foreach my $x (qw(setup recur)) {
4350     if ( my $discountnum = $self->get("${x}_discountnum") ) {
4351       my $cust_pkg_discount = FS::cust_pkg_discount->new( {
4352         'pkgnum'      => $self->pkgnum,
4353         'discountnum' => $discountnum,
4354         'setuprecur'  => $x,
4355         'months_used' => 0,
4356         'end_date'    => '', #XXX
4357         #for the create a new discount case
4358         'amount'      => $self->get("${x}_discountnum_amount"),
4359         'percent'     => $self->get("${x}_discountnum_percent"),
4360         'months'      => $self->get("${x}_discountnum_months"),
4361       } );
4362       if ( $x eq 'setup' ) {
4363         $cust_pkg_discount->setup('Y');
4364         $cust_pkg_discount->months('');
4365       }
4366       my $error = $cust_pkg_discount->insert;
4367       return $error if $error;
4368     }
4369   }
4370
4371   '';
4372 }
4373
4374 =item set_usage USAGE_VALUE_HASHREF 
4375
4376 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4377 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4378 upbytes, downbytes, and totalbytes are appropriate keys.
4379
4380 All svc_accts which are part of this package have their values reset.
4381
4382 =cut
4383
4384 sub set_usage {
4385   my ($self, $valueref, %opt) = @_;
4386
4387   #only svc_acct can set_usage for now
4388   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4389     my $svc_x = $cust_svc->svc_x;
4390     $svc_x->set_usage($valueref, %opt)
4391       if $svc_x->can("set_usage");
4392   }
4393 }
4394
4395 =item recharge USAGE_VALUE_HASHREF 
4396
4397 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4398 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4399 upbytes, downbytes, and totalbytes are appropriate keys.
4400
4401 All svc_accts which are part of this package have their values incremented.
4402
4403 =cut
4404
4405 sub recharge {
4406   my ($self, $valueref) = @_;
4407
4408   #only svc_acct can set_usage for now
4409   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4410     my $svc_x = $cust_svc->svc_x;
4411     $svc_x->recharge($valueref)
4412       if $svc_x->can("recharge");
4413   }
4414 }
4415
4416 =item apply_usageprice 
4417
4418 =cut
4419
4420 sub apply_usageprice {
4421   my $self = shift;
4422
4423   my $oldAutoCommit = $FS::UID::AutoCommit;
4424   local $FS::UID::AutoCommit = 0;
4425   my $dbh = dbh;
4426
4427   my $error = '';
4428
4429   foreach my $cust_pkg_usageprice ( $self->cust_pkg_usageprice ) {
4430     $error ||= $cust_pkg_usageprice->apply;
4431   }
4432
4433   if ( $error ) {
4434     $dbh->rollback if $oldAutoCommit;
4435     die "error applying part_pkg_usageprice add-ons, pkgnum ". $self->pkgnum.
4436         ": $error\n";
4437   } else {
4438     $dbh->commit if $oldAutoCommit;
4439   }
4440
4441
4442 }
4443
4444 =item cust_pkg_discount
4445
4446 =item cust_pkg_discount_active
4447
4448 =cut
4449
4450 sub cust_pkg_discount_active {
4451   my $self = shift;
4452   grep { $_->status eq 'active' } $self->cust_pkg_discount;
4453 }
4454
4455 =item cust_pkg_usage
4456
4457 Returns a list of all voice usage counters attached to this package.
4458
4459 =item apply_usage OPTIONS
4460
4461 Takes the following options:
4462 - cdr: a call detail record (L<FS::cdr>)
4463 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
4464 - minutes: the maximum number of minutes to be charged
4465
4466 Finds available usage minutes for a call of this class, and subtracts
4467 up to that many minutes from the usage pool.  If the usage pool is empty,
4468 and the C<cdr-minutes_priority> global config option is set, minutes may
4469 be taken from other calls as well.  Either way, an allocation record will
4470 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
4471 number of minutes of usage applied to the call.
4472
4473 =cut
4474
4475 sub apply_usage {
4476   my ($self, %opt) = @_;
4477   my $cdr = $opt{cdr};
4478   my $rate_detail = $opt{rate_detail};
4479   my $minutes = $opt{minutes};
4480   my $classnum = $rate_detail->classnum;
4481   my $pkgnum = $self->pkgnum;
4482   my $custnum = $self->custnum;
4483
4484   my $oldAutoCommit = $FS::UID::AutoCommit;
4485   local $FS::UID::AutoCommit = 0;
4486   my $dbh = dbh;
4487
4488   my $order = FS::Conf->new->config('cdr-minutes_priority');
4489
4490   my $is_classnum;
4491   if ( $classnum ) {
4492     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
4493   } else {
4494     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
4495   }
4496   my @usage_recs = qsearch({
4497       'table'     => 'cust_pkg_usage',
4498       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
4499                      ' JOIN cust_pkg             USING (pkgnum)'.
4500                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
4501       'select'    => 'cust_pkg_usage.*',
4502       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
4503                      " ( cust_pkg.custnum = $custnum AND ".
4504                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4505                      $is_classnum . ' AND '.
4506                      " cust_pkg_usage.minutes > 0",
4507       'order_by'  => " ORDER BY priority ASC",
4508   });
4509
4510   my $orig_minutes = $minutes;
4511   my $error;
4512   while (!$error and $minutes > 0 and @usage_recs) {
4513     my $cust_pkg_usage = shift @usage_recs;
4514     $cust_pkg_usage->select_for_update;
4515     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
4516         pkgusagenum => $cust_pkg_usage->pkgusagenum,
4517         acctid      => $cdr->acctid,
4518         minutes     => min($cust_pkg_usage->minutes, $minutes),
4519     });
4520     $cust_pkg_usage->set('minutes',
4521       $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes
4522     );
4523     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
4524     $minutes -= $cdr_cust_pkg_usage->minutes;
4525   }
4526   if ( $order and $minutes > 0 and !$error ) {
4527     # then try to steal minutes from another call
4528     my %search = (
4529         'table'     => 'cdr_cust_pkg_usage',
4530         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
4531                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
4532                        ' JOIN cust_pkg              USING (pkgnum)'.
4533                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
4534                        ' JOIN cdr                   USING (acctid)',
4535         'select'    => 'cdr_cust_pkg_usage.*',
4536         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
4537                        " ( cust_pkg.pkgnum = $pkgnum OR ".
4538                        " ( cust_pkg.custnum = $custnum AND ".
4539                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4540                        " part_pkg_usage_class.classnum = $classnum",
4541         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
4542     );
4543     if ( $order eq 'time' ) {
4544       # find CDRs that are using minutes, but have a later startdate
4545       # than this call
4546       my $startdate = $cdr->startdate;
4547       if ($startdate !~ /^\d+$/) {
4548         die "bad cdr startdate '$startdate'";
4549       }
4550       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
4551       # minimize needless reshuffling
4552       $search{'order_by'} .= ', cdr.startdate DESC';
4553     } else {
4554       # XXX may not work correctly with rate_time schedules.  Could 
4555       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
4556       # think...
4557       $search{'addl_from'} .=
4558         ' JOIN rate_detail'.
4559         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4560       if ( $order eq 'rate_high' ) {
4561         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4562                                 $rate_detail->min_charge;
4563         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4564       } elsif ( $order eq 'rate_low' ) {
4565         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4566                                 $rate_detail->min_charge;
4567         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4568       } else {
4569         #  this should really never happen
4570         die "invalid cdr-minutes_priority value '$order'\n";
4571       }
4572     }
4573     my @cdr_usage_recs = qsearch(\%search);
4574     my %reproc_cdrs;
4575     while (!$error and @cdr_usage_recs and $minutes > 0) {
4576       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4577       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4578       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4579       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4580       $cdr_cust_pkg_usage->select_for_update;
4581       $old_cdr->select_for_update;
4582       $cust_pkg_usage->select_for_update;
4583       # in case someone else stole the usage from this CDR
4584       # while waiting for the lock...
4585       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4586       # steal the usage allocation and flag the old CDR for reprocessing
4587       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4588       # if the allocation is more minutes than we need, adjust it...
4589       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4590       if ( $delta > 0 ) {
4591         $cdr_cust_pkg_usage->set('minutes', $minutes);
4592         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4593         $error = $cust_pkg_usage->replace;
4594       }
4595       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4596       $error ||= $cdr_cust_pkg_usage->replace;
4597       # deduct the stolen minutes
4598       $minutes -= $cdr_cust_pkg_usage->minutes;
4599     }
4600     # after all minute-stealing is done, reset the affected CDRs
4601     foreach (values %reproc_cdrs) {
4602       $error ||= $_->set_status('');
4603       # XXX or should we just call $cdr->rate right here?
4604       # it's not like we can create a loop this way, since the min_charge
4605       # or call time has to go monotonically in one direction.
4606       # we COULD get some very deep recursions going, though...
4607     }
4608   } # if $order and $minutes
4609   if ( $error ) {
4610     $dbh->rollback;
4611     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4612   } else {
4613     $dbh->commit if $oldAutoCommit;
4614     return $orig_minutes - $minutes;
4615   }
4616 }
4617
4618 =item supplemental_pkgs
4619
4620 Returns a list of all packages supplemental to this one.
4621
4622 =cut
4623
4624 sub supplemental_pkgs {
4625   my $self = shift;
4626   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4627 }
4628
4629 =item main_pkg
4630
4631 Returns the package that this one is supplemental to, if any.
4632
4633 =cut
4634
4635 sub main_pkg {
4636   my $self = shift;
4637   if ( $self->main_pkgnum ) {
4638     return FS::cust_pkg->by_key($self->main_pkgnum);
4639   }
4640   return;
4641 }
4642
4643 =back
4644
4645 =head1 CLASS METHODS
4646
4647 =over 4
4648
4649 =item recurring_sql
4650
4651 Returns an SQL expression identifying recurring packages.
4652
4653 =cut
4654
4655 sub recurring_sql { "
4656   '0' != ( select freq from part_pkg
4657              where cust_pkg.pkgpart = part_pkg.pkgpart )
4658 "; }
4659
4660 =item onetime_sql
4661
4662 Returns an SQL expression identifying one-time packages.
4663
4664 =cut
4665
4666 sub onetime_sql { "
4667   '0' = ( select freq from part_pkg
4668             where cust_pkg.pkgpart = part_pkg.pkgpart )
4669 "; }
4670
4671 =item ordered_sql
4672
4673 Returns an SQL expression identifying ordered packages (recurring packages not
4674 yet billed).
4675
4676 =cut
4677
4678 sub ordered_sql {
4679    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4680 }
4681
4682 =item active_sql
4683
4684 Returns an SQL expression identifying active packages.
4685
4686 =cut
4687
4688 sub active_sql {
4689   $_[0]->recurring_sql. "
4690   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4691   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4692   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4693 "; }
4694
4695 =item not_yet_billed_sql
4696
4697 Returns an SQL expression identifying packages which have not yet been billed.
4698
4699 =cut
4700
4701 sub not_yet_billed_sql { "
4702       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4703   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4704   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4705 "; }
4706
4707 =item inactive_sql
4708
4709 Returns an SQL expression identifying inactive packages (one-time packages
4710 that are otherwise unsuspended/uncancelled).
4711
4712 =cut
4713
4714 sub inactive_sql { "
4715   ". $_[0]->onetime_sql(). "
4716   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4717   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4718   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4719 "; }
4720
4721 =item on_hold_sql
4722
4723 Returns an SQL expression identifying on-hold packages.
4724
4725 =cut
4726
4727 sub on_hold_sql {
4728   #$_[0]->recurring_sql(). ' AND '.
4729   "
4730         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel  = 0 )
4731     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp   != 0
4732     AND ( cust_pkg.setup  IS     NULL  OR cust_pkg.setup   = 0 )
4733   ";
4734 }
4735
4736 =item susp_sql
4737 =item suspended_sql
4738
4739 Returns an SQL expression identifying suspended packages.
4740
4741 =cut
4742
4743 sub suspended_sql { susp_sql(@_); }
4744 sub susp_sql {
4745   #$_[0]->recurring_sql(). ' AND '.
4746   "
4747         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4748     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4749     AND   cust_pkg.setup  IS NOT NULL AND cust_pkg.setup != 0
4750   ";
4751 }
4752
4753 =item cancel_sql
4754 =item cancelled_sql
4755
4756 Returns an SQL exprression identifying cancelled packages.
4757
4758 =cut
4759
4760 sub cancelled_sql { cancel_sql(@_); }
4761 sub cancel_sql { 
4762   #$_[0]->recurring_sql(). ' AND '.
4763   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4764 }
4765
4766 =item status_sql
4767
4768 Returns an SQL expression to give the package status as a string.
4769
4770 =cut
4771
4772 sub status_sql {
4773 "CASE
4774   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4775   WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold'
4776   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4777   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4778   WHEN ".onetime_sql()." THEN 'one-time charge'
4779   ELSE 'active'
4780 END"
4781 }
4782
4783 =item fcc_477_count
4784
4785 Returns a list of two package counts.  The first is a count of packages
4786 based on the supplied criteria and the second is the count of residential
4787 packages with those same criteria.  Criteria are specified as in the search
4788 method.
4789
4790 =cut
4791
4792 sub fcc_477_count {
4793   my ($class, $params) = @_;
4794
4795   my $sql_query = $class->search( $params );
4796
4797   my $count_sql = delete($sql_query->{'count_query'});
4798   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4799     or die "couldn't parse count_sql";
4800
4801   my $count_sth = dbh->prepare($count_sql)
4802     or die "Error preparing $count_sql: ". dbh->errstr;
4803   $count_sth->execute
4804     or die "Error executing $count_sql: ". $count_sth->errstr;
4805   my $count_arrayref = $count_sth->fetchrow_arrayref;
4806
4807   return ( @$count_arrayref );
4808
4809 }
4810
4811 =item tax_locationnum_sql
4812
4813 Returns an SQL expression for the tax location for a package, based
4814 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4815
4816 =cut
4817
4818 sub tax_locationnum_sql {
4819   my $conf = FS::Conf->new;
4820   if ( $conf->exists('tax-pkg_address') ) {
4821     'cust_pkg.locationnum';
4822   }
4823   elsif ( $conf->exists('tax-ship_address') ) {
4824     'cust_main.ship_locationnum';
4825   }
4826   else {
4827     'cust_main.bill_locationnum';
4828   }
4829 }
4830
4831 =item location_sql
4832
4833 Returns a list: the first item is an SQL fragment identifying matching 
4834 packages/customers via location (taking into account shipping and package
4835 address taxation, if enabled), and subsequent items are the parameters to
4836 substitute for the placeholders in that fragment.
4837
4838 =cut
4839
4840 sub location_sql {
4841   my($class, %opt) = @_;
4842   my $ornull = $opt{'ornull'};
4843
4844   my $conf = new FS::Conf;
4845
4846   # '?' placeholders in _location_sql_where
4847   my $x = $ornull ? 3 : 2;
4848   my @bill_param = ( 
4849     ('district')x3,
4850     ('city')x3, 
4851     ('county')x$x,
4852     ('state')x$x,
4853     'country'
4854   );
4855
4856   my $main_where;
4857   my @main_param;
4858   if ( $conf->exists('tax-ship_address') ) {
4859
4860     $main_where = "(
4861          (     ( ship_last IS NULL     OR  ship_last  = '' )
4862            AND ". _location_sql_where('cust_main', '', $ornull ). "
4863          )
4864       OR (       ship_last IS NOT NULL AND ship_last != ''
4865            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4866          )
4867     )";
4868     #    AND payby != 'COMP'
4869
4870     @main_param = ( @bill_param, @bill_param );
4871
4872   } else {
4873
4874     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4875     @main_param = @bill_param;
4876
4877   }
4878
4879   my $where;
4880   my @param;
4881   if ( $conf->exists('tax-pkg_address') ) {
4882
4883     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4884
4885     $where = " (
4886                     ( cust_pkg.locationnum IS     NULL AND $main_where )
4887                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
4888                )
4889              ";
4890     @param = ( @main_param, @bill_param );
4891   
4892   } else {
4893
4894     $where = $main_where;
4895     @param = @main_param;
4896
4897   }
4898
4899   ( $where, @param );
4900
4901 }
4902
4903 #subroutine, helper for location_sql
4904 sub _location_sql_where {
4905   my $table  = shift;
4906   my $prefix = @_ ? shift : '';
4907   my $ornull = @_ ? shift : '';
4908
4909 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4910
4911   $ornull = $ornull ? ' OR ? IS NULL ' : '';
4912
4913   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
4914   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
4915   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
4916
4917   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4918
4919 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
4920   "
4921         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4922     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4923     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
4924     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
4925     AND   $table.${prefix}country  = ?
4926   ";
4927 }
4928
4929 sub _X_show_zero {
4930   my( $self, $what ) = @_;
4931
4932   my $what_show_zero = $what. '_show_zero';
4933   length($self->$what_show_zero())
4934     ? ($self->$what_show_zero() eq 'Y')
4935     : $self->part_pkg->$what_show_zero();
4936 }
4937
4938 =head1 SUBROUTINES
4939
4940 =over 4
4941
4942 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
4943
4944 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
4945 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
4946
4947 CUSTNUM is a customer (see L<FS::cust_main>)
4948
4949 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4950 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
4951 permitted.
4952
4953 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
4954 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
4955 new billing items.  An error is returned if this is not possible (see
4956 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
4957 parameter.
4958
4959 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4960 newly-created cust_pkg objects.
4961
4962 REFNUM, if specified, will specify the FS::pkg_referral record to be created
4963 and inserted.  Multiple FS::pkg_referral records can be created by
4964 setting I<refnum> to an array reference of refnums or a hash reference with
4965 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
4966 record will be created corresponding to cust_main.refnum.
4967
4968 =cut
4969
4970 sub order {
4971   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
4972
4973   my $conf = new FS::Conf;
4974
4975   # Transactionize this whole mess
4976   my $oldAutoCommit = $FS::UID::AutoCommit;
4977   local $FS::UID::AutoCommit = 0;
4978   my $dbh = dbh;
4979
4980   my $error;
4981 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
4982 #  return "Customer not found: $custnum" unless $cust_main;
4983
4984   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
4985     if $DEBUG;
4986
4987   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4988                          @$remove_pkgnum;
4989
4990   my $change = scalar(@old_cust_pkg) != 0;
4991
4992   my %hash = (); 
4993   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
4994
4995     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
4996          " to pkgpart ". $pkgparts->[0]. "\n"
4997       if $DEBUG;
4998
4999     my $err_or_cust_pkg =
5000       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
5001                                 'refnum'  => $refnum,
5002                               );
5003
5004     unless (ref($err_or_cust_pkg)) {
5005       $dbh->rollback if $oldAutoCommit;
5006       return $err_or_cust_pkg;
5007     }
5008
5009     push @$return_cust_pkg, $err_or_cust_pkg;
5010     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5011     return '';
5012
5013   }
5014
5015   # Create the new packages.
5016   foreach my $pkgpart (@$pkgparts) {
5017
5018     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
5019
5020     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
5021                                       pkgpart => $pkgpart,
5022                                       refnum  => $refnum,
5023                                       %hash,
5024                                     };
5025     $error = $cust_pkg->insert( 'change' => $change );
5026     push @$return_cust_pkg, $cust_pkg;
5027
5028     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
5029       my $supp_pkg = FS::cust_pkg->new({
5030           custnum => $custnum,
5031           pkgpart => $link->dst_pkgpart,
5032           refnum  => $refnum,
5033           main_pkgnum => $cust_pkg->pkgnum,
5034           %hash,
5035       });
5036       $error ||= $supp_pkg->insert( 'change' => $change );
5037       push @$return_cust_pkg, $supp_pkg;
5038     }
5039
5040     if ($error) {
5041       $dbh->rollback if $oldAutoCommit;
5042       return $error;
5043     }
5044
5045   }
5046   # $return_cust_pkg now contains refs to all of the newly 
5047   # created packages.
5048
5049   # Transfer services and cancel old packages.
5050   foreach my $old_pkg (@old_cust_pkg) {
5051
5052     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
5053       if $DEBUG;
5054
5055     foreach my $new_pkg (@$return_cust_pkg) {
5056       $error = $old_pkg->transfer($new_pkg);
5057       if ($error and $error == 0) {
5058         # $old_pkg->transfer failed.
5059         $dbh->rollback if $oldAutoCommit;
5060         return $error;
5061       }
5062     }
5063
5064     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
5065       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
5066       foreach my $new_pkg (@$return_cust_pkg) {
5067         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
5068         if ($error and $error == 0) {
5069           # $old_pkg->transfer failed.
5070         $dbh->rollback if $oldAutoCommit;
5071         return $error;
5072         }
5073       }
5074     }
5075
5076     if ($error > 0) {
5077       # Transfers were successful, but we went through all of the 
5078       # new packages and still had services left on the old package.
5079       # We can't cancel the package under the circumstances, so abort.
5080       $dbh->rollback if $oldAutoCommit;
5081       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
5082     }
5083     $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 );
5084     if ($error) {
5085       $dbh->rollback;
5086       return $error;
5087     }
5088   }
5089   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5090   '';
5091 }
5092
5093 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
5094
5095 A bulk change method to change packages for multiple customers.
5096
5097 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5098 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
5099 permitted.
5100
5101 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5102 replace.  The services (see L<FS::cust_svc>) are moved to the
5103 new billing items.  An error is returned if this is not possible (see
5104 L<FS::pkg_svc>).
5105
5106 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5107 newly-created cust_pkg objects.
5108
5109 =cut
5110
5111 sub bulk_change {
5112   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5113
5114   # Transactionize this whole mess
5115   my $oldAutoCommit = $FS::UID::AutoCommit;
5116   local $FS::UID::AutoCommit = 0;
5117   my $dbh = dbh;
5118
5119   my @errors;
5120   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5121                          @$remove_pkgnum;
5122
5123   while(scalar(@old_cust_pkg)) {
5124     my @return = ();
5125     my $custnum = $old_cust_pkg[0]->custnum;
5126     my (@remove) = map { $_->pkgnum }
5127                    grep { $_->custnum == $custnum } @old_cust_pkg;
5128     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5129
5130     my $error = order $custnum, $pkgparts, \@remove, \@return;
5131
5132     push @errors, $error
5133       if $error;
5134     push @$return_cust_pkg, @return;
5135   }
5136
5137   if (scalar(@errors)) {
5138     $dbh->rollback if $oldAutoCommit;
5139     return join(' / ', @errors);
5140   }
5141
5142   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5143   '';
5144 }
5145
5146 =item forward_emails
5147
5148 Returns a hash of svcnums and corresponding email addresses
5149 for svc_acct services that can be used as source or dest
5150 for svc_forward services provisioned in this package.
5151
5152 Accepts options I<svc_forward> OR I<svcnum> for a svc_forward
5153 service;  if included, will ensure the current values of the
5154 specified service are included in the list, even if for some
5155 other reason they wouldn't be.  If called as a class method
5156 with a specified service, returns only these current values.
5157
5158 Caution: does not actually check if svc_forward services are
5159 available to be provisioned on this package.
5160
5161 =cut
5162
5163 sub forward_emails {
5164   my $self = shift;
5165   my %opt = @_;
5166
5167   #load optional service, thoroughly validated
5168   die "Use svcnum or svc_forward, not both"
5169     if $opt{'svcnum'} && $opt{'svc_forward'};
5170   my $svc_forward = $opt{'svc_forward'};
5171   $svc_forward ||= qsearchs('svc_forward',{ 'svcnum' => $opt{'svcnum'} })
5172     if $opt{'svcnum'};
5173   die "Specified service is not a forward service"
5174     if $svc_forward && (ref($svc_forward) ne 'FS::svc_forward');
5175   die "Specified service not found"
5176     if ($opt{'svcnum'} || $opt{'svc_forward'}) && !$svc_forward;
5177
5178   my %email;
5179
5180   ## everything below was basically copied from httemplate/edit/svc_forward.cgi 
5181   ## with minimal refactoring, not sure why we can't just load all svc_accts for this custnum
5182
5183   #add current values from specified service, if there was one
5184   if ($svc_forward) {
5185     foreach my $method (qw( srcsvc_acct dstsvc_acct )) {
5186       my $svc_acct = $svc_forward->$method();
5187       $email{$svc_acct->svcnum} = $svc_acct->email if $svc_acct;
5188     }
5189   }
5190
5191   if (ref($self) eq 'FS::cust_pkg') {
5192
5193     #and including the rest for this customer
5194     my($u_part_svc,@u_acct_svcparts);
5195     foreach $u_part_svc ( qsearch('part_svc',{'svcdb'=>'svc_acct'}) ) {
5196       push @u_acct_svcparts,$u_part_svc->getfield('svcpart');
5197     }
5198
5199     my $custnum = $self->getfield('custnum');
5200     foreach my $i_cust_pkg ( qsearch('cust_pkg',{'custnum'=>$custnum}) ) {
5201       my $cust_pkgnum = $i_cust_pkg->getfield('pkgnum');
5202       #now find the corresponding record(s) in cust_svc (for this pkgnum!)
5203       foreach my $acct_svcpart (@u_acct_svcparts) {
5204         foreach my $i_cust_svc (
5205           qsearch( 'cust_svc', { 'pkgnum'  => $cust_pkgnum,
5206                                  'svcpart' => $acct_svcpart } )
5207         ) {
5208           my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $i_cust_svc->svcnum } );
5209           $email{$svc_acct->svcnum} = $svc_acct->email;
5210         }  
5211       }
5212     }
5213   }
5214
5215   return %email;
5216 }
5217
5218 # Used by FS::Upgrade to migrate to a new database.
5219 sub _upgrade_data {  # class method
5220   my ($class, %opts) = @_;
5221   $class->_upgrade_otaker(%opts);
5222   my @statements = (
5223     # RT#10139, bug resulting in contract_end being set when it shouldn't
5224   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5225     # RT#10830, bad calculation of prorate date near end of year
5226     # the date range for bill is December 2009, and we move it forward
5227     # one year if it's before the previous bill date (which it should 
5228     # never be)
5229   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5230   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5231   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5232     # RT6628, add order_date to cust_pkg
5233     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5234         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5235         history_action = \'insert\') where order_date is null',
5236   );
5237   foreach my $sql (@statements) {
5238     my $sth = dbh->prepare($sql);
5239     $sth->execute or die $sth->errstr;
5240   }
5241
5242   # RT31194: supplemental package links that are deleted don't clean up 
5243   # linked records
5244   my @pkglinknums = qsearch({
5245       'select'    => 'DISTINCT cust_pkg.pkglinknum',
5246       'table'     => 'cust_pkg',
5247       'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ',
5248       'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL 
5249                         AND part_pkg_link.pkglinknum IS NULL',
5250   });
5251   foreach (@pkglinknums) {
5252     my $pkglinknum = $_->pkglinknum;
5253     warn "cleaning part_pkg_link #$pkglinknum\n";
5254     my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum});
5255     my $error = $part_pkg_link->remove_linked;
5256     die $error if $error;
5257   }
5258 }
5259
5260 =back
5261
5262 =head1 BUGS
5263
5264 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5265
5266 In sub order, the @pkgparts array (passed by reference) is clobbered.
5267
5268 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5269 method to pass dates to the recur_prog expression, it should do so.
5270
5271 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5272 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5273 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5274 configuration values.  Probably need a subroutine which decides what to do
5275 based on whether or not we've fetched the user yet, rather than a hash.  See
5276 FS::UID and the TODO.
5277
5278 Now that things are transactional should the check in the insert method be
5279 moved to check ?
5280
5281 =head1 SEE ALSO
5282
5283 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5284 L<FS::pkg_svc>, schema.html from the base documentation
5285
5286 =cut
5287
5288 1;
5289