074a58c9b4d317d3b7534cb361ce1ee102d448c7
[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->get('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 going to be
2157   # credited for remaining time, don't keep setup, bill, or last_bill dates,
2158   # and DO pass the flag to cancel() to credit the customer.  If the old
2159   # package had a setup date, set the new package's setup to the package
2160   # change date so that it has the same status as before.
2161   if ( $opt->{'pkgpart'} 
2162        and $opt->{'pkgpart'} != $self->pkgpart
2163        and $self->part_pkg->option('unused_credit_change', 1) ) {
2164     $unused_credit = 1;
2165     $keep_dates = 0;
2166     $hash{'last_bill'} = '';
2167     $hash{'bill'} = '';
2168   }
2169
2170   if ( $keep_dates ) {
2171     foreach my $date ( qw(setup bill last_bill) ) {
2172       $hash{$date} = $self->getfield($date);
2173     }
2174   }
2175   # always keep the following dates
2176   foreach my $date (qw(order_date susp adjourn cancel expire resume 
2177                     start_date contract_end)) {
2178     $hash{$date} = $self->getfield($date);
2179   }
2180   # but if contract_end was explicitly specified, that overrides all else
2181   $hash{'contract_end'} = $opt->{'contract_end'}
2182     if $opt->{'contract_end'};
2183
2184   # allow $opt->{'locationnum'} = '' to specifically set it to null
2185   # (i.e. customer default location)
2186   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2187
2188   # usually this doesn't matter.  the two cases where it does are:
2189   # 1. unused_credit_change + pkgpart change + setup fee on the new package
2190   # and
2191   # 2. (more importantly) changing a package before it's billed
2192   $hash{'waive_setup'} = $self->waive_setup;
2193
2194   # if this package is scheduled for a future package change, preserve that
2195   $hash{'change_to_pkgnum'} = $self->change_to_pkgnum;
2196
2197   my $custnum = $self->custnum;
2198   if ( $opt->{cust_main} ) {
2199     my $cust_main = $opt->{cust_main};
2200     unless ( $cust_main->custnum ) { 
2201       my $error = $cust_main->insert( @{ $opt->{cust_main_insert_args}||[] } );
2202       if ( $error ) {
2203         $dbh->rollback if $oldAutoCommit;
2204         return "inserting customer record: $error";
2205       }
2206     }
2207     $custnum = $cust_main->custnum;
2208   }
2209
2210   $hash{'contactnum'} = $opt->{'contactnum'} if $opt->{'contactnum'};
2211
2212   my $cust_pkg;
2213   if ( $opt->{'cust_pkg'} ) {
2214     # The target package already exists; update it to show that it was 
2215     # changed from this package.
2216     $cust_pkg = $opt->{'cust_pkg'};
2217
2218     # follow all the above rules for date changes, etc.
2219     foreach (keys %hash) {
2220       $cust_pkg->set($_, $hash{$_});
2221     }
2222     # except those that implement the future package change behavior
2223     foreach (qw(change_to_pkgnum start_date expire)) {
2224       $cust_pkg->set($_, '');
2225     }
2226
2227     $error = $cust_pkg->replace;
2228
2229   } else {
2230     # Create the new package.
2231     $cust_pkg = new FS::cust_pkg {
2232       custnum     => $custnum,
2233       locationnum => $opt->{'locationnum'},
2234       ( map {  $_ => ( $opt->{$_} || $self->$_() )  }
2235           qw( pkgpart quantity refnum salesnum )
2236       ),
2237       %hash,
2238     };
2239     $error = $cust_pkg->insert( 'change' => 1,
2240                                 'allow_pkgpart' => $same_pkgpart );
2241   }
2242   if ($error) {
2243     $dbh->rollback if $oldAutoCommit;
2244     return "inserting new package: $error";
2245   }
2246
2247   # Transfer services and cancel old package.
2248   # Enforce service limits only if this is a pkgpart change.
2249   local $FS::cust_svc::ignore_quantity;
2250   $FS::cust_svc::ignore_quantity = 1 if $same_pkgpart;
2251   $error = $self->transfer($cust_pkg);
2252   if ($error and $error == 0) {
2253     # $old_pkg->transfer failed.
2254     $dbh->rollback if $oldAutoCommit;
2255     return "transferring $error";
2256   }
2257
2258   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
2259     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
2260     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
2261     if ($error and $error == 0) {
2262       # $old_pkg->transfer failed.
2263       $dbh->rollback if $oldAutoCommit;
2264       return "converting $error";
2265     }
2266   }
2267
2268   # We set unprotect_svcs when executing a "future package change".  It's 
2269   # not a user-interactive operation, so returning an error means the 
2270   # package change will just fail.  Rather than have that happen, we'll 
2271   # let leftover services be deleted.
2272   if ($error > 0 and !$opt->{'unprotect_svcs'}) {
2273     # Transfers were successful, but we still had services left on the old
2274     # package.  We can't change the package under this circumstances, so abort.
2275     $dbh->rollback if $oldAutoCommit;
2276     return "unable to transfer all services";
2277   }
2278
2279   #reset usage if changing pkgpart
2280   # AND usage rollover is off (otherwise adds twice, now and at package bill)
2281   if ($self->pkgpart != $cust_pkg->pkgpart) {
2282     my $part_pkg = $cust_pkg->part_pkg;
2283     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
2284                                                  ? ()
2285                                                  : ( 'null' => 1 )
2286                                    )
2287       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
2288
2289     if ($error) {
2290       $dbh->rollback if $oldAutoCommit;
2291       return "setting usage values: $error";
2292     }
2293   } else {
2294     # if NOT changing pkgpart, transfer any usage pools over
2295     foreach my $usage ($self->cust_pkg_usage) {
2296       $usage->set('pkgnum', $cust_pkg->pkgnum);
2297       $error = $usage->replace;
2298       if ( $error ) {
2299         $dbh->rollback if $oldAutoCommit;
2300         return "transferring usage pools: $error";
2301       }
2302     }
2303   }
2304
2305   # transfer usage pricing add-ons, if we're not changing pkgpart or if they were specified
2306   if ( $same_pkgpart || $opt->{'cust_pkg_usageprice'}) {
2307     my @old_cust_pkg_usageprice;
2308     if ($opt->{'cust_pkg_usageprice'}) {
2309       @old_cust_pkg_usageprice = @{ $opt->{'cust_pkg_usageprice'} };
2310     } else {
2311       @old_cust_pkg_usageprice = $self->cust_pkg_usageprice;
2312     }
2313     foreach my $old_cust_pkg_usageprice (@old_cust_pkg_usageprice) {
2314       my $new_cust_pkg_usageprice = new FS::cust_pkg_usageprice {
2315         'pkgnum'         => $cust_pkg->pkgnum,
2316         'usagepricepart' => $old_cust_pkg_usageprice->usagepricepart,
2317         'quantity'       => $old_cust_pkg_usageprice->quantity,
2318       };
2319       $error = $new_cust_pkg_usageprice->insert;
2320       if ( $error ) {
2321         $dbh->rollback if $oldAutoCommit;
2322         return "Error transferring usage pricing add-on: $error";
2323       }
2324     }
2325   }
2326
2327   # transfer discounts, if we're not changing pkgpart
2328   if ( $same_pkgpart ) {
2329     foreach my $old_discount ($self->cust_pkg_discount_active) {
2330       # don't remove the old discount, we may still need to bill that package.
2331       my $new_discount = new FS::cust_pkg_discount {
2332         'pkgnum'      => $cust_pkg->pkgnum,
2333         'discountnum' => $old_discount->discountnum,
2334         'months_used' => $old_discount->months_used,
2335       };
2336       $error = $new_discount->insert;
2337       if ( $error ) {
2338         $dbh->rollback if $oldAutoCommit;
2339         return "transferring discounts: $error";
2340       }
2341     }
2342   }
2343
2344   # transfer (copy) invoice details
2345   foreach my $detail ($self->cust_pkg_detail) {
2346     my $new_detail = FS::cust_pkg_detail->new({ $detail->hash });
2347     $new_detail->set('pkgdetailnum', '');
2348     $new_detail->set('pkgnum', $cust_pkg->pkgnum);
2349     $error = $new_detail->insert;
2350     if ( $error ) {
2351       $dbh->rollback if $oldAutoCommit;
2352       return "transferring package notes: $error";
2353     }
2354   }
2355   
2356   my @new_supp_pkgs;
2357
2358   if ( !$opt->{'cust_pkg'} ) {
2359     # Order any supplemental packages.
2360     my $part_pkg = $cust_pkg->part_pkg;
2361     my @old_supp_pkgs = $self->supplemental_pkgs;
2362     foreach my $link ($part_pkg->supp_part_pkg_link) {
2363       my $old;
2364       foreach (@old_supp_pkgs) {
2365         if ($_->pkgpart == $link->dst_pkgpart) {
2366           $old = $_;
2367           $_->pkgpart(0); # so that it can't match more than once
2368         }
2369         last if $old;
2370       }
2371       # false laziness with FS::cust_main::Packages::order_pkg
2372       my $new = FS::cust_pkg->new({
2373           pkgpart       => $link->dst_pkgpart,
2374           pkglinknum    => $link->pkglinknum,
2375           custnum       => $custnum,
2376           main_pkgnum   => $cust_pkg->pkgnum,
2377           locationnum   => $cust_pkg->locationnum,
2378           start_date    => $cust_pkg->start_date,
2379           order_date    => $cust_pkg->order_date,
2380           expire        => $cust_pkg->expire,
2381           adjourn       => $cust_pkg->adjourn,
2382           contract_end  => $cust_pkg->contract_end,
2383           refnum        => $cust_pkg->refnum,
2384           discountnum   => $cust_pkg->discountnum,
2385           waive_setup   => $cust_pkg->waive_setup,
2386       });
2387       if ( $old and $opt->{'keep_dates'} ) {
2388         foreach (qw(setup bill last_bill)) {
2389           $new->set($_, $old->get($_));
2390         }
2391       }
2392       $error = $new->insert( allow_pkgpart => $same_pkgpart );
2393       # transfer services
2394       if ( $old ) {
2395         $error ||= $old->transfer($new);
2396       }
2397       if ( $error and $error > 0 ) {
2398         # no reason why this should ever fail, but still...
2399         $error = "Unable to transfer all services from supplemental package ".
2400           $old->pkgnum;
2401       }
2402       if ( $error ) {
2403         $dbh->rollback if $oldAutoCommit;
2404         return $error;
2405       }
2406       push @new_supp_pkgs, $new;
2407     }
2408   } # if !$opt->{'cust_pkg'}
2409     # because if there is one, then supplemental packages would already
2410     # have been created for it.
2411
2412   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
2413   #remaining time.
2414   #Don't allow billing the package (preceding period packages and/or 
2415   #outstanding usage) if we are keeping dates (i.e. location changing), 
2416   #because the new package will be billed for the same date range.
2417   #Supplemental packages are also canceled here.
2418
2419   # during scheduled changes, avoid canceling the package we just
2420   # changed to (duh)
2421   $self->set('change_to_pkgnum' => '');
2422
2423   $error = $self->cancel(
2424     quiet          => 1, 
2425     unused_credit  => $unused_credit,
2426     nobill         => $keep_dates,
2427     change_custnum => ( $self->custnum != $custnum ? $custnum : '' ),
2428     no_delay_cancel => 1,
2429   );
2430   if ($error) {
2431     $dbh->rollback if $oldAutoCommit;
2432     return "canceling old package: $error";
2433   }
2434
2435   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
2436     #$self->cust_main
2437     my $error = $cust_pkg->cust_main->bill( 
2438       'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
2439     );
2440     if ( $error ) {
2441       $dbh->rollback if $oldAutoCommit;
2442       return "billing new package: $error";
2443     }
2444   }
2445
2446   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2447
2448   $cust_pkg;
2449
2450 }
2451
2452 =item change_later OPTION => VALUE...
2453
2454 Schedule a package change for a later date.  This actually orders the new
2455 package immediately, but sets its start date for a future date, and sets
2456 the current package to expire on the same date.
2457
2458 If the package is already scheduled for a change, this can be called with 
2459 'start_date' to change the scheduled date, or with pkgpart and/or 
2460 locationnum to modify the package change.  To cancel the scheduled change 
2461 entirely, see C<abort_change>.
2462
2463 Options include:
2464
2465 =over 4
2466
2467 =item start_date
2468
2469 The date for the package change.  Required, and must be in the future.
2470
2471 =item pkgpart
2472
2473 =item locationnum
2474
2475 =item quantity
2476
2477 =item contract_end
2478
2479 The pkgpart, locationnum, quantity and optional contract_end of the new 
2480 package, with the same meaning as in C<change>.
2481
2482 =back
2483
2484 =cut
2485
2486 sub change_later {
2487   my $self = shift;
2488   my $opt = ref($_[0]) ? shift : { @_ };
2489
2490   # check contract_end, prevent adding/removing
2491   my $error = $self->_check_change($opt);
2492   return $error if $error;
2493
2494   my $oldAutoCommit = $FS::UID::AutoCommit;
2495   local $FS::UID::AutoCommit = 0;
2496   my $dbh = dbh;
2497
2498   my $cust_main = $self->cust_main;
2499
2500   my $date = delete $opt->{'start_date'} or return 'start_date required';
2501  
2502   if ( $date <= time ) {
2503     $dbh->rollback if $oldAutoCommit;
2504     return "start_date $date is in the past";
2505   }
2506
2507   if ( $self->change_to_pkgnum ) {
2508     my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
2509     my $new_pkgpart = $opt->{'pkgpart'}
2510         if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $change_to->pkgpart;
2511     my $new_locationnum = $opt->{'locationnum'}
2512         if $opt->{'locationnum'} and $opt->{'locationnum'} != $change_to->locationnum;
2513     my $new_quantity = $opt->{'quantity'}
2514         if $opt->{'quantity'} and $opt->{'quantity'} != $change_to->quantity;
2515     my $new_contract_end = $opt->{'contract_end'}
2516         if $opt->{'contract_end'} and $opt->{'contract_end'} != $change_to->contract_end;
2517     if ( $new_pkgpart or $new_locationnum or $new_quantity or $new_contract_end ) {
2518       # it hasn't been billed yet, so in principle we could just edit
2519       # it in place (w/o a package change), but that's bad form.
2520       # So change the package according to the new options...
2521       my $err_or_pkg = $change_to->change(%$opt);
2522       if ( ref $err_or_pkg ) {
2523         # Then set that package up for a future start.
2524         $self->set('change_to_pkgnum', $err_or_pkg->pkgnum);
2525         $self->set('expire', $date); # in case it's different
2526         $err_or_pkg->set('start_date', $date);
2527         $err_or_pkg->set('change_date', '');
2528         $err_or_pkg->set('change_pkgnum', '');
2529
2530         $error = $self->replace       ||
2531                  $err_or_pkg->replace ||
2532                  #because change() might've edited existing scheduled change in place
2533                  (($err_or_pkg->pkgnum == $change_to->pkgnum) ? '' :
2534                   $change_to->cancel('no_delay_cancel' => 1) ||
2535                   $change_to->delete);
2536       } else {
2537         $error = $err_or_pkg;
2538       }
2539     } else { # change the start date only.
2540       $self->set('expire', $date);
2541       $change_to->set('start_date', $date);
2542       $error = $self->replace || $change_to->replace;
2543     }
2544     if ( $error ) {
2545       $dbh->rollback if $oldAutoCommit;
2546       return $error;
2547     } else {
2548       $dbh->commit if $oldAutoCommit;
2549       return '';
2550     }
2551   } # if $self->change_to_pkgnum
2552
2553   my $new_pkgpart = $opt->{'pkgpart'}
2554       if $opt->{'pkgpart'} and $opt->{'pkgpart'} != $self->pkgpart;
2555   my $new_locationnum = $opt->{'locationnum'}
2556       if $opt->{'locationnum'} and $opt->{'locationnum'} != $self->locationnum;
2557   my $new_quantity = $opt->{'quantity'}
2558       if $opt->{'quantity'} and $opt->{'quantity'} != $self->quantity;
2559   my $new_contract_end = $opt->{'contract_end'}
2560       if $opt->{'contract_end'} and $opt->{'contract_end'} != $self->contract_end;
2561
2562   return '' unless $new_pkgpart or $new_locationnum or $new_quantity or $new_contract_end; # wouldn't do anything
2563
2564   # allow $opt->{'locationnum'} = '' to specifically set it to null
2565   # (i.e. customer default location)
2566   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
2567
2568   my $new = FS::cust_pkg->new( {
2569     custnum     => $self->custnum,
2570     locationnum => $opt->{'locationnum'},
2571     start_date  => $date,
2572     map   {  $_ => ( $opt->{$_} || $self->$_() )  }
2573       qw( pkgpart quantity refnum salesnum contract_end )
2574   } );
2575   $error = $new->insert('change' => 1, 
2576                         'allow_pkgpart' => ($new_pkgpart ? 0 : 1));
2577   if ( !$error ) {
2578     $self->set('change_to_pkgnum', $new->pkgnum);
2579     $self->set('expire', $date);
2580     $error = $self->replace;
2581   }
2582   if ( $error ) {
2583     $dbh->rollback if $oldAutoCommit;
2584   } else {
2585     $dbh->commit if $oldAutoCommit;
2586   }
2587
2588   $error;
2589 }
2590
2591 =item abort_change
2592
2593 Cancels a future package change scheduled by C<change_later>.
2594
2595 =cut
2596
2597 sub abort_change {
2598   my $self = shift;
2599   my $oldAutoCommit = $FS::UID::AutoCommit;
2600   local $FS::UID::AutoCommit = 0;
2601
2602   my $pkgnum = $self->change_to_pkgnum;
2603   my $change_to = FS::cust_pkg->by_key($pkgnum) if $pkgnum;
2604   my $error;
2605   $self->set('change_to_pkgnum', '');
2606   $self->set('expire', '');
2607   $error = $self->replace;
2608   if ( $change_to ) {
2609     $error ||= $change_to->cancel || $change_to->delete;
2610   }
2611
2612   if ( $oldAutoCommit ) {
2613     if ( $error ) {
2614       dbh->rollback;
2615     } else {
2616       dbh->commit;
2617     }
2618   }
2619
2620   return $error;
2621 }
2622
2623 =item set_quantity QUANTITY
2624
2625 Change the package's quantity field.  This is one of the few package properties
2626 that can safely be changed without canceling and reordering the package
2627 (because it doesn't affect tax eligibility).  Returns an error or an 
2628 empty string.
2629
2630 =cut
2631
2632 sub set_quantity {
2633   my $self = shift;
2634   $self = $self->replace_old; # just to make sure
2635   $self->quantity(shift);
2636   $self->replace;
2637 }
2638
2639 =item set_salesnum SALESNUM
2640
2641 Change the package's salesnum (sales person) field.  This is one of the few
2642 package properties that can safely be changed without canceling and reordering
2643 the package (because it doesn't affect tax eligibility).  Returns an error or
2644 an empty string.
2645
2646 =cut
2647
2648 sub set_salesnum {
2649   my $self = shift;
2650   $self = $self->replace_old; # just to make sure
2651   $self->salesnum(shift);
2652   $self->replace;
2653   # XXX this should probably reassign any credit that's already been given
2654 }
2655
2656 =item modify_charge OPTIONS
2657
2658 Change the properties of a one-time charge.  The following properties can
2659 be changed this way:
2660 - pkg: the package description
2661 - classnum: the package class
2662 - additional: arrayref of additional invoice details to add to this package
2663
2664 and, I<if the charge has not yet been billed>:
2665 - start_date: the date when it will be billed
2666 - amount: the setup fee to be charged
2667 - quantity: the multiplier for the setup fee
2668 - separate_bill: whether to put the charge on a separate invoice
2669
2670 If you pass 'adjust_commission' => 1, and the classnum changes, and there are
2671 commission credits linked to this charge, they will be recalculated.
2672
2673 =cut
2674
2675 sub modify_charge {
2676   my $self = shift;
2677   my %opt = @_;
2678   my $part_pkg = $self->part_pkg;
2679   my $pkgnum = $self->pkgnum;
2680
2681   my $dbh = dbh;
2682   my $oldAutoCommit = $FS::UID::AutoCommit;
2683   local $FS::UID::AutoCommit = 0;
2684
2685   return "Can't use modify_charge except on one-time charges"
2686     unless $part_pkg->freq eq '0';
2687
2688   if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) {
2689     $part_pkg->set('pkg', $opt{'pkg'});
2690   }
2691
2692   my %pkg_opt = $part_pkg->options;
2693   my $pkg_opt_modified = 0;
2694
2695   $opt{'additional'} ||= [];
2696   my $i;
2697   my @old_additional;
2698   foreach (grep /^additional/, keys %pkg_opt) {
2699     ($i) = ($_ =~ /^additional_info(\d+)$/);
2700     $old_additional[$i] = $pkg_opt{$_} if $i;
2701     delete $pkg_opt{$_};
2702   }
2703
2704   for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) {
2705     $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i];
2706     if (!exists($old_additional[$i])
2707         or $old_additional[$i] ne $opt{'additional'}->[$i])
2708     {
2709       $pkg_opt_modified = 1;
2710     }
2711   }
2712   $pkg_opt_modified = 1 if (scalar(@old_additional) - 1) != $i;
2713   $pkg_opt{'additional_count'} = $i if $i > 0;
2714
2715   my $old_classnum;
2716   if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} )
2717   {
2718     # remember it
2719     $old_classnum = $part_pkg->classnum;
2720     $part_pkg->set('classnum', $opt{'classnum'});
2721   }
2722
2723   if ( !$self->get('setup') ) {
2724     # not yet billed, so allow amount, setup_cost, quantity, start_date,
2725     # and separate_bill
2726
2727     if ( exists($opt{'amount'}) 
2728           and $part_pkg->option('setup_fee') != $opt{'amount'}
2729           and $opt{'amount'} > 0 ) {
2730
2731       $pkg_opt{'setup_fee'} = $opt{'amount'};
2732       $pkg_opt_modified = 1;
2733     }
2734
2735     if ( exists($opt{'setup_cost'}) 
2736           and $part_pkg->setup_cost != $opt{'setup_cost'}
2737           and $opt{'setup_cost'} > 0 ) {
2738
2739       $part_pkg->set('setup_cost', $opt{'setup_cost'});
2740     }
2741
2742     if ( exists($opt{'quantity'})
2743           and $opt{'quantity'} != $self->quantity
2744           and $opt{'quantity'} > 0 ) {
2745         
2746       $self->set('quantity', $opt{'quantity'});
2747     }
2748
2749     if ( exists($opt{'start_date'})
2750           and $opt{'start_date'} != $self->start_date ) {
2751
2752       $self->set('start_date', $opt{'start_date'});
2753     }
2754
2755     if ( exists($opt{'separate_bill'})
2756           and $opt{'separate_bill'} ne $self->separate_bill ) {
2757
2758       $self->set('separate_bill', $opt{'separate_bill'});
2759     }
2760
2761
2762   } # else simply ignore them; the UI shouldn't allow editing the fields
2763
2764   
2765   if ( exists($opt{'taxclass'}) 
2766           and $part_pkg->taxclass ne $opt{'taxclass'}) {
2767     
2768       $part_pkg->set('taxclass', $opt{'taxclass'});
2769   }
2770
2771   my $error;
2772   if ( $part_pkg->modified or $pkg_opt_modified ) {
2773     # can we safely modify the package def?
2774     # Yes, if it's not available for purchase, and this is the only instance
2775     # of it.
2776     if ( $part_pkg->disabled
2777          and FS::cust_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 1
2778          and FS::quotation_pkg->count('pkgpart = '.$part_pkg->pkgpart) == 0
2779        ) {
2780       $error = $part_pkg->replace( options => \%pkg_opt );
2781     } else {
2782       # clone it
2783       $part_pkg = $part_pkg->clone;
2784       $part_pkg->set('disabled' => 'Y');
2785       $error = $part_pkg->insert( options => \%pkg_opt );
2786       # and associate this as yet-unbilled package to the new package def
2787       $self->set('pkgpart' => $part_pkg->pkgpart);
2788     }
2789     if ( $error ) {
2790       $dbh->rollback if $oldAutoCommit;
2791       return $error;
2792     }
2793   }
2794
2795   if ($self->modified) { # for quantity or start_date change, or if we had
2796                          # to clone the existing package def
2797     my $error = $self->replace;
2798     return $error if $error;
2799   }
2800   if (defined $old_classnum) {
2801     # fix invoice grouping records
2802     my $old_catname = $old_classnum
2803                       ? FS::pkg_class->by_key($old_classnum)->categoryname
2804                       : '';
2805     my $new_catname = $opt{'classnum'}
2806                       ? $part_pkg->pkg_class->categoryname
2807                       : '';
2808     if ( $old_catname ne $new_catname ) {
2809       foreach my $cust_bill_pkg ($self->cust_bill_pkg) {
2810         # (there should only be one...)
2811         my @display = qsearch( 'cust_bill_pkg_display', {
2812             'billpkgnum'  => $cust_bill_pkg->billpkgnum,
2813             'section'     => $old_catname,
2814         });
2815         foreach (@display) {
2816           $_->set('section', $new_catname);
2817           $error = $_->replace;
2818           if ( $error ) {
2819             $dbh->rollback if $oldAutoCommit;
2820             return $error;
2821           }
2822         }
2823       } # foreach $cust_bill_pkg
2824     }
2825
2826     if ( $opt{'adjust_commission'} ) {
2827       # fix commission credits...tricky.
2828       foreach my $cust_event ($self->cust_event) {
2829         my $part_event = $cust_event->part_event;
2830         foreach my $table (qw(sales agent)) {
2831           my $class =
2832             "FS::part_event::Action::Mixin::credit_${table}_pkg_class";
2833           my $credit = qsearchs('cust_credit', {
2834               'eventnum' => $cust_event->eventnum,
2835           });
2836           if ( $part_event->isa($class) ) {
2837             # Yes, this results in current commission rates being applied 
2838             # retroactively to a one-time charge.  For accounting purposes 
2839             # there ought to be some kind of time limit on doing this.
2840             my $amount = $part_event->_calc_credit($self);
2841             if ( $credit and $credit->amount ne $amount ) {
2842               # Void the old credit.
2843               $error = $credit->void('Package class changed');
2844               if ( $error ) {
2845                 $dbh->rollback if $oldAutoCommit;
2846                 return "$error (adjusting commission credit)";
2847               }
2848             }
2849             # redo the event action to recreate the credit.
2850             local $@ = '';
2851             eval { $part_event->do_action( $self, $cust_event ) };
2852             if ( $@ ) {
2853               $dbh->rollback if $oldAutoCommit;
2854               return $@;
2855             }
2856           } # if $part_event->isa($class)
2857         } # foreach $table
2858       } # foreach $cust_event
2859     } # if $opt{'adjust_commission'}
2860   } # if defined $old_classnum
2861
2862   $dbh->commit if $oldAutoCommit;
2863   '';
2864 }
2865
2866
2867
2868 use Data::Dumper;
2869 sub process_bulk_cust_pkg {
2870   my $job = shift;
2871   my $param = shift;
2872   warn Dumper($param) if $DEBUG;
2873
2874   my $old_part_pkg = qsearchs('part_pkg', 
2875                               { pkgpart => $param->{'old_pkgpart'} });
2876   my $new_part_pkg = qsearchs('part_pkg',
2877                               { pkgpart => $param->{'new_pkgpart'} });
2878   die "Must select a new package type\n" unless $new_part_pkg;
2879   #my $keep_dates = $param->{'keep_dates'} || 0;
2880   my $keep_dates = 1; # there is no good reason to turn this off
2881
2882   my $oldAutoCommit = $FS::UID::AutoCommit;
2883   local $FS::UID::AutoCommit = 0;
2884   my $dbh = dbh;
2885
2886   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
2887
2888   my $i = 0;
2889   foreach my $old_cust_pkg ( @cust_pkgs ) {
2890     $i++;
2891     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
2892     if ( $old_cust_pkg->getfield('cancel') ) {
2893       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
2894         $old_cust_pkg->pkgnum."\n"
2895         if $DEBUG;
2896       next;
2897     }
2898     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
2899       if $DEBUG;
2900     my $error = $old_cust_pkg->change(
2901       'pkgpart'     => $param->{'new_pkgpart'},
2902       'keep_dates'  => $keep_dates
2903     );
2904     if ( !ref($error) ) { # change returns the cust_pkg on success
2905       $dbh->rollback;
2906       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
2907     }
2908   }
2909   $dbh->commit if $oldAutoCommit;
2910   return;
2911 }
2912
2913 =item last_bill
2914
2915 Returns the last bill date, or if there is no last bill date, the setup date.
2916 Useful for billing metered services.
2917
2918 =cut
2919
2920 sub last_bill {
2921   my $self = shift;
2922   return $self->setfield('last_bill', $_[0]) if @_;
2923   return $self->getfield('last_bill') if $self->getfield('last_bill');
2924   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
2925                                                   'edate'  => $self->bill,  } );
2926   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
2927 }
2928
2929 =item last_cust_pkg_reason ACTION
2930
2931 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
2932 Returns false if there is no reason or the package is not currenly ACTION'd
2933 ACTION is one of adjourn, susp, cancel, or expire.
2934
2935 =cut
2936
2937 sub last_cust_pkg_reason {
2938   my ( $self, $action ) = ( shift, shift );
2939   my $date = $self->get($action);
2940   qsearchs( {
2941               'table' => 'cust_pkg_reason',
2942               'hashref' => { 'pkgnum' => $self->pkgnum,
2943                              'action' => substr(uc($action), 0, 1),
2944                              'date'   => $date,
2945                            },
2946               'order_by' => 'ORDER BY num DESC LIMIT 1',
2947            } );
2948 }
2949
2950 =item last_reason ACTION
2951
2952 Returns the most recent ACTION FS::reason associated with the package.
2953 Returns false if there is no reason or the package is not currenly ACTION'd
2954 ACTION is one of adjourn, susp, cancel, or expire.
2955
2956 =cut
2957
2958 sub last_reason {
2959   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
2960   $cust_pkg_reason->reason
2961     if $cust_pkg_reason;
2962 }
2963
2964 =item part_pkg
2965
2966 Returns the definition for this billing item, as an FS::part_pkg object (see
2967 L<FS::part_pkg>).
2968
2969 =cut
2970
2971 sub part_pkg {
2972   my $self = shift;
2973   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
2974   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
2975   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
2976 }
2977
2978 =item old_cust_pkg
2979
2980 Returns the cancelled package this package was changed from, if any.
2981
2982 =cut
2983
2984 sub old_cust_pkg {
2985   my $self = shift;
2986   return '' unless $self->change_pkgnum;
2987   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
2988 }
2989
2990 =item change_cust_main
2991
2992 Returns the customter this package was detached to, if any.
2993
2994 =cut
2995
2996 sub change_cust_main {
2997   my $self = shift;
2998   return '' unless $self->change_custnum;
2999   qsearchs('cust_main', { 'custnum' => $self->change_custnum } );
3000 }
3001
3002 =item calc_setup
3003
3004 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
3005 item.
3006
3007 =cut
3008
3009 sub calc_setup {
3010   my $self = shift;
3011   $self->part_pkg->calc_setup($self, @_);
3012 }
3013
3014 =item calc_recur
3015
3016 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
3017 item.
3018
3019 =cut
3020
3021 sub calc_recur {
3022   my $self = shift;
3023   $self->part_pkg->calc_recur($self, @_);
3024 }
3025
3026 =item base_setup
3027
3028 Calls the I<base_setup> of the FS::part_pkg object associated with this billing
3029 item.
3030
3031 =cut
3032
3033 sub base_setup {
3034   my $self = shift;
3035   $self->part_pkg->base_setup($self, @_);
3036 }
3037
3038 =item base_recur
3039
3040 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
3041 item.
3042
3043 =cut
3044
3045 sub base_recur {
3046   my $self = shift;
3047   $self->part_pkg->base_recur($self, @_);
3048 }
3049
3050 =item calc_remain
3051
3052 Calls the I<calc_remain> of the FS::part_pkg object associated with this
3053 billing item.
3054
3055 =cut
3056
3057 sub calc_remain {
3058   my $self = shift;
3059   $self->part_pkg->calc_remain($self, @_);
3060 }
3061
3062 =item calc_cancel
3063
3064 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
3065 billing item.
3066
3067 =cut
3068
3069 sub calc_cancel {
3070   my $self = shift;
3071   $self->part_pkg->calc_cancel($self, @_);
3072 }
3073
3074 =item cust_bill_pkg
3075
3076 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
3077
3078 =cut
3079
3080 sub cust_bill_pkg {
3081   my $self = shift;
3082   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
3083 }
3084
3085 =item cust_pkg_detail [ DETAILTYPE ]
3086
3087 Returns any customer package details for this package (see
3088 L<FS::cust_pkg_detail>).
3089
3090 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
3091
3092 =cut
3093
3094 sub cust_pkg_detail {
3095   my $self = shift;
3096   my %hash = ( 'pkgnum' => $self->pkgnum );
3097   $hash{detailtype} = shift if @_;
3098   qsearch({
3099     'table'    => 'cust_pkg_detail',
3100     'hashref'  => \%hash,
3101     'order_by' => 'ORDER BY weight, pkgdetailnum',
3102   });
3103 }
3104
3105 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
3106
3107 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
3108
3109 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
3110
3111 If there is an error, returns the error, otherwise returns false.
3112
3113 =cut
3114
3115 sub set_cust_pkg_detail {
3116   my( $self, $detailtype, @details ) = @_;
3117
3118   my $oldAutoCommit = $FS::UID::AutoCommit;
3119   local $FS::UID::AutoCommit = 0;
3120   my $dbh = dbh;
3121
3122   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
3123     my $error = $current->delete;
3124     if ( $error ) {
3125       $dbh->rollback if $oldAutoCommit;
3126       return "error removing old detail: $error";
3127     }
3128   }
3129
3130   foreach my $detail ( @details ) {
3131     my $cust_pkg_detail = new FS::cust_pkg_detail {
3132       'pkgnum'     => $self->pkgnum,
3133       'detailtype' => $detailtype,
3134       'detail'     => $detail,
3135     };
3136     my $error = $cust_pkg_detail->insert;
3137     if ( $error ) {
3138       $dbh->rollback if $oldAutoCommit;
3139       return "error adding new detail: $error";
3140     }
3141
3142   }
3143
3144   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3145   '';
3146
3147 }
3148
3149 =item cust_event
3150
3151 Returns the customer billing events (see L<FS::cust_event>) for this invoice.
3152
3153 =cut
3154
3155 #false laziness w/cust_bill.pm
3156 sub cust_event {
3157   my $self = shift;
3158   qsearch({
3159     'table'     => 'cust_event',
3160     'addl_from' => 'JOIN part_event USING ( eventpart )',
3161     'hashref'   => { 'tablenum' => $self->pkgnum },
3162     'extra_sql' => " AND eventtable = 'cust_pkg' ",
3163   });
3164 }
3165
3166 =item num_cust_event
3167
3168 Returns the number of customer billing events (see L<FS::cust_event>) for this package.
3169
3170 =cut
3171
3172 #false laziness w/cust_bill.pm
3173 sub num_cust_event {
3174   my $self = shift;
3175   my $sql = "SELECT COUNT(*) ". $self->_from_cust_event_where;
3176   $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref->[0];
3177 }
3178
3179 =item exists_cust_event
3180
3181 Returns true if there are customer billing events (see L<FS::cust_event>) for this package.  More efficient than using num_cust_event.
3182
3183 =cut
3184
3185 sub exists_cust_event {
3186   my $self = shift;
3187   my $sql = "SELECT 1 ". $self->_from_cust_event_where. " LIMIT 1";
3188   my $row = $self->_prep_ex($sql, $self->pkgnum)->fetchrow_arrayref;
3189   $row ? $row->[0] : '';
3190 }
3191
3192 sub _from_cust_event_where {
3193   #my $self = shift;
3194   " FROM cust_event JOIN part_event USING ( eventpart ) ".
3195   "  WHERE tablenum = ? AND eventtable = 'cust_pkg' ";
3196 }
3197
3198 sub _prep_ex {
3199   my( $self, $sql, @args ) = @_;
3200   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
3201   $sth->execute(@args)         or die $sth->errstr. " executing $sql";
3202   $sth;
3203 }
3204
3205 =item part_pkg_currency_option OPTIONNAME
3206
3207 Returns a two item list consisting of the currency of this customer, if any,
3208 and a value for the provided option.  If the customer has a currency, the value
3209 is the option value the given name and the currency (see
3210 L<FS::part_pkg_currency>).  Otherwise, if the customer has no currency, is the
3211 regular option value for the given name (see L<FS::part_pkg_option>).
3212
3213 =cut
3214
3215 sub part_pkg_currency_option {
3216   my( $self, $optionname ) = @_;
3217   my $part_pkg = $self->part_pkg;
3218   if ( my $currency = $self->cust_main->currency ) {
3219     ($currency, $part_pkg->part_pkg_currency_option($currency, $optionname) );
3220   } else {
3221     ('', $part_pkg->option($optionname) );
3222   }
3223 }
3224
3225 =item cust_svc [ SVCPART ] (old, deprecated usage)
3226
3227 =item cust_svc [ OPTION => VALUE ... ] (current usage)
3228
3229 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
3230
3231 Returns the services for this package, as FS::cust_svc objects (see
3232 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
3233 spcififed, returns only the matching services.
3234
3235 As an optimization, use the cust_svc_unsorted version if you are not displaying
3236 the results.
3237
3238 =cut
3239
3240 sub cust_svc {
3241   my $self = shift;
3242   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
3243   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) );
3244 }
3245
3246 sub cust_svc_unsorted {
3247   my $self = shift;
3248   @{ $self->cust_svc_unsorted_arrayref(@_) };
3249 }
3250
3251 sub cust_svc_unsorted_arrayref {
3252   my $self = shift;
3253
3254   return [] unless $self->num_cust_svc(@_);
3255
3256   my %opt = ();
3257   if ( @_ && $_[0] =~ /^\d+/ ) {
3258     $opt{svcpart} = shift;
3259   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3260     %opt = %{ $_[0] };
3261   } elsif ( @_ ) {
3262     %opt = @_;
3263   }
3264
3265   my %search = (
3266     'select'    => 'cust_svc.*, part_svc.*',
3267     'table'     => 'cust_svc',
3268     'hashref'   => { 'pkgnum' => $self->pkgnum },
3269     'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )',
3270   );
3271   $search{hashref}->{svcpart} = $opt{svcpart}
3272     if $opt{svcpart};
3273   $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{svcdb} )
3274     if $opt{svcdb};
3275
3276   [ qsearch(\%search) ];
3277
3278 }
3279
3280 =item overlimit [ SVCPART ]
3281
3282 Returns the services for this package which have exceeded their
3283 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
3284 is specified, return only the matching services.
3285
3286 =cut
3287
3288 sub overlimit {
3289   my $self = shift;
3290   return () unless $self->num_cust_svc(@_);
3291   grep { $_->overlimit } $self->cust_svc(@_);
3292 }
3293
3294 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3295
3296 Returns historical services for this package created before END TIMESTAMP and
3297 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
3298 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
3299 I<pkg_svc.hidden> flag will be omitted.
3300
3301 =cut
3302
3303 sub h_cust_svc {
3304   my $self = shift;
3305   warn "$me _h_cust_svc called on $self\n"
3306     if $DEBUG;
3307
3308   my ($end, $start, $mode) = @_;
3309
3310   local($FS::Record::qsearch_qualify_columns) = 0;
3311
3312   my @cust_svc = $self->_sort_cust_svc(
3313     [ qsearch( 'h_cust_svc',
3314       { 'pkgnum' => $self->pkgnum, },  
3315       FS::h_cust_svc->sql_h_search(@_),  
3316     ) ]
3317   );
3318
3319   if ( defined($mode) && $mode eq 'I' ) {
3320     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
3321     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
3322   } else {
3323     return @cust_svc;
3324   }
3325 }
3326
3327 sub _sort_cust_svc {
3328   my( $self, $arrayref ) = @_;
3329
3330   my $sort =
3331     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
3332
3333   my %pkg_svc = map { $_->svcpart => $_ }
3334                 qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
3335
3336   map  { $_->[0] }
3337   sort $sort
3338   map {
3339         my $pkg_svc = $pkg_svc{ $_->svcpart } || '';
3340         [ $_,
3341           $pkg_svc ? $pkg_svc->primary_svc : '',
3342           $pkg_svc ? $pkg_svc->quantity : 0,
3343         ];
3344       }
3345   @$arrayref;
3346
3347 }
3348
3349 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
3350
3351 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
3352
3353 Returns the number of services for this package.  Available options are svcpart
3354 and svcdb.  If either is spcififed, returns only the matching services.
3355
3356 =cut
3357
3358 sub num_cust_svc {
3359   my $self = shift;
3360
3361   return $self->{'_num_cust_svc'}
3362     if !scalar(@_)
3363        && exists($self->{'_num_cust_svc'})
3364        && $self->{'_num_cust_svc'} =~ /\d/;
3365
3366   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
3367     if $DEBUG > 2;
3368
3369   my %opt = ();
3370   if ( @_ && $_[0] =~ /^\d+/ ) {
3371     $opt{svcpart} = shift;
3372   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
3373     %opt = %{ $_[0] };
3374   } elsif ( @_ ) {
3375     %opt = @_;
3376   }
3377
3378   my $select = 'SELECT COUNT(*) FROM cust_svc ';
3379   my $where = ' WHERE pkgnum = ? ';
3380   my @param = ($self->pkgnum);
3381
3382   if ( $opt{'svcpart'} ) {
3383     $where .= ' AND svcpart = ? ';
3384     push @param, $opt{'svcpart'};
3385   }
3386   if ( $opt{'svcdb'} ) {
3387     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
3388     $where .= ' AND svcdb = ? ';
3389     push @param, $opt{'svcdb'};
3390   }
3391
3392   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
3393   $sth->execute(@param) or die $sth->errstr;
3394   $sth->fetchrow_arrayref->[0];
3395 }
3396
3397 =item available_part_svc 
3398
3399 Returns a list of FS::part_svc objects representing services included in this
3400 package but not yet provisioned.  Each FS::part_svc object also has an extra
3401 field, I<num_avail>, which specifies the number of available services.
3402
3403 Accepts option I<provision_hold>;  if true, only returns part_svc for which the
3404 associated pkg_svc has the provision_hold flag set.
3405
3406 =cut
3407
3408 sub available_part_svc {
3409   my $self = shift;
3410   my %opt  = @_;
3411
3412   my $pkg_quantity = $self->quantity || 1;
3413
3414   grep { $_->num_avail > 0 }
3415   map {
3416     my $part_svc = $_->part_svc;
3417     $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
3418     $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
3419
3420     # more evil encapsulation breakage
3421     if ($part_svc->{'Hash'}{'num_avail'} > 0) {
3422       my @exports = $part_svc->part_export_did;
3423       $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
3424         }
3425
3426     $part_svc;
3427   }
3428   grep { $opt{'provision_hold'} ? $_->provision_hold : 1 }
3429   $self->part_pkg->pkg_svc;
3430 }
3431
3432 =item part_svc [ OPTION => VALUE ... ]
3433
3434 Returns a list of FS::part_svc objects representing provisioned and available
3435 services included in this package.  Each FS::part_svc object also has the
3436 following extra fields:
3437
3438 =over 4
3439
3440 =item num_cust_svc
3441
3442 (count)
3443
3444 =item num_avail
3445
3446 (quantity - count)
3447
3448 =item cust_pkg_svc
3449
3450 (services) - array reference containing the provisioned services, as cust_svc objects
3451
3452 =back
3453
3454 Accepts two options:
3455
3456 =over 4
3457
3458 =item summarize_size
3459
3460 If true, will omit the extra cust_pkg_svc option for objects where num_cust_svc
3461 is this size or greater.
3462
3463 =item hide_discontinued
3464
3465 If true, will omit looking for services that are no longer avaialble in the
3466 package definition.
3467
3468 =back
3469
3470 =cut
3471
3472 #svcnum
3473 #label -> ($cust_svc->label)[1]
3474
3475 sub part_svc {
3476   my $self = shift;
3477   my %opt = @_;
3478
3479   my $pkg_quantity = $self->quantity || 1;
3480
3481   #XXX some sort of sort order besides numeric by svcpart...
3482   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
3483     my $pkg_svc = $_;
3484     my $part_svc = $pkg_svc->part_svc;
3485     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3486     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
3487     $part_svc->{'Hash'}{'num_avail'}    =
3488       max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
3489     $part_svc->{'Hash'}{'cust_pkg_svc'} =
3490         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
3491       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
3492           && $num_cust_svc >= $opt{summarize_size};
3493     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
3494     $part_svc;
3495   } $self->part_pkg->pkg_svc;
3496
3497   unless ( $opt{hide_discontinued} ) {
3498     #extras
3499     push @part_svc, map {
3500       my $part_svc = $_;
3501       my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
3502       $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
3503       $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
3504       $part_svc->{'Hash'}{'cust_pkg_svc'} =
3505         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
3506       $part_svc;
3507     } $self->extra_part_svc;
3508   }
3509
3510   @part_svc;
3511
3512 }
3513
3514 =item extra_part_svc
3515
3516 Returns a list of FS::part_svc objects corresponding to services in this
3517 package which are still provisioned but not (any longer) available in the
3518 package definition.
3519
3520 =cut
3521
3522 sub extra_part_svc {
3523   my $self = shift;
3524
3525   my $pkgnum  = $self->pkgnum;
3526   #my $pkgpart = $self->pkgpart;
3527
3528 #  qsearch( {
3529 #    'table'     => 'part_svc',
3530 #    'hashref'   => {},
3531 #    'extra_sql' =>
3532 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
3533 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
3534 #                       AND pkg_svc.pkgpart = ?
3535 #                       AND quantity > 0 
3536 #                 )
3537 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
3538 #                       LEFT JOIN cust_pkg USING ( pkgnum )
3539 #                     WHERE cust_svc.svcpart = part_svc.svcpart
3540 #                       AND pkgnum = ?
3541 #                 )",
3542 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
3543 #  } );
3544
3545 #seems to benchmark slightly faster... (or did?)
3546
3547   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
3548   my $pkgparts = join(',', @pkgparts);
3549
3550   qsearch( {
3551     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
3552     #MySQL doesn't grok DISINCT ON
3553     'select'      => 'DISTINCT part_svc.*',
3554     'table'       => 'part_svc',
3555     'addl_from'   =>
3556       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
3557                                AND pkg_svc.pkgpart IN ($pkgparts)
3558                                AND quantity > 0
3559                              )
3560        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
3561        LEFT JOIN cust_pkg USING ( pkgnum )
3562       ",
3563     'hashref'     => {},
3564     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
3565     'extra_param' => [ [$self->pkgnum=>'int'] ],
3566   } );
3567 }
3568
3569 =item status
3570
3571 Returns a short status string for this package, currently:
3572
3573 =over 4
3574
3575 =item on hold
3576
3577 =item not yet billed
3578
3579 =item one-time charge
3580
3581 =item active
3582
3583 =item suspended
3584
3585 =item cancelled
3586
3587 =back
3588
3589 =cut
3590
3591 sub status {
3592   my $self = shift;
3593
3594   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
3595
3596   return 'cancelled' if $self->get('cancel');
3597   return 'on hold' if $self->susp && ! $self->setup;
3598   return 'suspended' if $self->susp;
3599   return 'not yet billed' unless $self->setup;
3600   return 'one-time charge' if $freq =~ /^(0|$)/;
3601   return 'active';
3602 }
3603
3604 =item ucfirst_status
3605
3606 Returns the status with the first character capitalized.
3607
3608 =cut
3609
3610 sub ucfirst_status {
3611   ucfirst(shift->status);
3612 }
3613
3614 =item statuses
3615
3616 Class method that returns the list of possible status strings for packages
3617 (see L<the status method|/status>).  For example:
3618
3619   @statuses = FS::cust_pkg->statuses();
3620
3621 =cut
3622
3623 tie my %statuscolor, 'Tie::IxHash', 
3624   'on hold'         => 'FF00F5', #brighter purple!
3625   'not yet billed'  => '009999', #teal? cyan?
3626   'one-time charge' => '0000CC', #blue  #'000000',
3627   'active'          => '00CC00',
3628   'suspended'       => 'FF9900',
3629   'cancelled'       => 'FF0000',
3630 ;
3631
3632 sub statuses {
3633   my $self = shift; #could be class...
3634   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
3635   #                                    # mayble split btw one-time vs. recur
3636     keys %statuscolor;
3637 }
3638
3639 sub statuscolors {
3640   #my $self = shift;
3641   \%statuscolor;
3642 }
3643
3644 =item statuscolor
3645
3646 Returns a hex triplet color string for this package's status.
3647
3648 =cut
3649
3650 sub statuscolor {
3651   my $self = shift;
3652   $statuscolor{$self->status};
3653 }
3654
3655 =item is_status_delay_cancel
3656
3657 Returns true if part_pkg has option delay_cancel, 
3658 cust_pkg status is 'suspended' and expire is set
3659 to cancel package within the next day (or however
3660 many days are set in global config part_pkg-delay_cancel-days.
3661
3662 Accepts option I<part_pkg-delay_cancel-days> which should be
3663 the value of the config setting, to avoid looking it up again.
3664
3665 This is not a real status, this only meant for hacking display 
3666 values, because otherwise treating the package as suspended is 
3667 really the whole point of the delay_cancel option.
3668
3669 =cut
3670
3671 sub is_status_delay_cancel {
3672   my ($self,%opt) = @_;
3673   if ( $self->main_pkgnum and $self->pkglinknum ) {
3674     return $self->main_pkg->is_status_delay_cancel;
3675   }
3676   return 0 unless $self->part_pkg->option('delay_cancel',1);
3677   return 0 unless $self->status eq 'suspended';
3678   return 0 unless $self->expire;
3679   my $expdays = $opt{'part_pkg-delay_cancel-days'};
3680   unless ($expdays) {
3681     my $conf = new FS::Conf;
3682     $expdays = $conf->config('part_pkg-delay_cancel-days') || 1;
3683   }
3684   my $expsecs = 60*60*24*$expdays;
3685   return 0 unless $self->expire < time + $expsecs;
3686   return 1;
3687 }
3688
3689 =item pkg_label
3690
3691 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
3692 "pkg - comment" depending on user preference).
3693
3694 =cut
3695
3696 sub pkg_label {
3697   my $self = shift;
3698   my $label = $self->part_pkg->pkg_comment( cust_pkg=>$self, nopkgpart=>1 );
3699   $label = $self->pkgnum. ": $label"
3700     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
3701   $label;
3702 }
3703
3704 =item pkg_label_long
3705
3706 Returns a long label for this package, adding the primary service's label to
3707 pkg_label.
3708
3709 =cut
3710
3711 sub pkg_label_long {
3712   my $self = shift;
3713   my $label = $self->pkg_label;
3714   my $cust_svc = $self->primary_cust_svc;
3715   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
3716   $label;
3717 }
3718
3719 =item pkg_locale
3720
3721 Returns a customer-localized label for this package.
3722
3723 =cut
3724
3725 sub pkg_locale {
3726   my $self = shift;
3727   $self->part_pkg->pkg_locale( $self->cust_main->locale );
3728 }
3729
3730 =item primary_cust_svc
3731
3732 Returns a primary service (as FS::cust_svc object) if one can be identified.
3733
3734 =cut
3735
3736 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
3737
3738 sub primary_cust_svc {
3739   my $self = shift;
3740
3741   my @cust_svc = $self->cust_svc;
3742
3743   return '' unless @cust_svc; #no serivces - irrelevant then
3744   
3745   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
3746
3747   # primary service as specified in the package definition
3748   # or exactly one service definition with quantity one
3749   my $svcpart = $self->part_pkg->svcpart;
3750   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
3751   return $cust_svc[0] if scalar(@cust_svc) == 1;
3752
3753   #couldn't identify one thing..
3754   return '';
3755 }
3756
3757 =item labels
3758
3759 Returns a list of lists, calling the label method for all services
3760 (see L<FS::cust_svc>) of this billing item.
3761
3762 =cut
3763
3764 sub labels {
3765   my $self = shift;
3766   map { [ $_->label ] } $self->cust_svc;
3767 }
3768
3769 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
3770
3771 Like the labels method, but returns historical information on services that
3772 were active as of END_TIMESTAMP and (optionally) not cancelled before
3773 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
3774 I<pkg_svc.hidden> flag will be omitted.
3775
3776 Returns a list of lists, calling the label method for all (historical) services
3777 (see L<FS::h_cust_svc>) of this billing item.
3778
3779 =cut
3780
3781 sub h_labels {
3782   my $self = shift;
3783   warn "$me _h_labels called on $self\n"
3784     if $DEBUG;
3785   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
3786 }
3787
3788 =item labels_short
3789
3790 Like labels, except returns a simple flat list, and shortens long
3791 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3792 identical services to one line that lists the service label and the number of
3793 individual services rather than individual items.
3794
3795 =cut
3796
3797 sub labels_short {
3798   shift->_labels_short( 'labels', @_ );
3799 }
3800
3801 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
3802
3803 Like h_labels, except returns a simple flat list, and shortens long
3804 (currently >5 or the cust_bill-max_same_services configuration value) lists of
3805 identical services to one line that lists the service label and the number of
3806 individual services rather than individual items.
3807
3808 =cut
3809
3810 sub h_labels_short {
3811   shift->_labels_short( 'h_labels', @_ );
3812 }
3813
3814 sub _labels_short {
3815   my( $self, $method ) = ( shift, shift );
3816
3817   warn "$me _labels_short called on $self with $method method\n"
3818     if $DEBUG;
3819
3820   my $conf = new FS::Conf;
3821   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
3822
3823   warn "$me _labels_short populating \%labels\n"
3824     if $DEBUG;
3825
3826   my %labels;
3827   #tie %labels, 'Tie::IxHash';
3828   push @{ $labels{$_->[0]} }, $_->[1]
3829     foreach $self->$method(@_);
3830
3831   warn "$me _labels_short populating \@labels\n"
3832     if $DEBUG;
3833
3834   my @labels;
3835   foreach my $label ( keys %labels ) {
3836     my %seen = ();
3837     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
3838     my $num = scalar(@values);
3839     warn "$me _labels_short $num items for $label\n"
3840       if $DEBUG;
3841
3842     if ( $num > $max_same_services ) {
3843       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
3844         if $DEBUG;
3845       push @labels, "$label ($num)";
3846     } else {
3847       if ( $conf->exists('cust_bill-consolidate_services') ) {
3848         warn "$me _labels_short   consolidating services\n"
3849           if $DEBUG;
3850         # push @labels, "$label: ". join(', ', @values);
3851         while ( @values ) {
3852           my $detail = "$label: ";
3853           $detail .= shift(@values). ', '
3854             while @values
3855                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
3856           $detail =~ s/, $//;
3857           push @labels, $detail;
3858         }
3859         warn "$me _labels_short   done consolidating services\n"
3860           if $DEBUG;
3861       } else {
3862         warn "$me _labels_short   adding service data\n"
3863           if $DEBUG;
3864         push @labels, map { "$label: $_" } @values;
3865       }
3866     }
3867   }
3868
3869  @labels;
3870
3871 }
3872
3873 =item cust_main
3874
3875 Returns the parent customer object (see L<FS::cust_main>).
3876
3877 =item balance
3878
3879 Returns the balance for this specific package, when using
3880 experimental package balance.
3881
3882 =cut
3883
3884 sub balance {
3885   my $self = shift;
3886   $self->cust_main->balance_pkgnum( $self->pkgnum );
3887 }
3888
3889 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
3890
3891 =item cust_location
3892
3893 Returns the location object, if any (see L<FS::cust_location>).
3894
3895 =item cust_location_or_main
3896
3897 If this package is associated with a location, returns the locaiton (see
3898 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
3899
3900 =item location_label [ OPTION => VALUE ... ]
3901
3902 Returns the label of the location object (see L<FS::cust_location>).
3903
3904 =cut
3905
3906 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
3907
3908 =item tax_locationnum
3909
3910 Returns the foreign key to a L<FS::cust_location> object for calculating  
3911 tax on this package, as determined by the C<tax-pkg_address> and 
3912 C<tax-ship_address> configuration flags.
3913
3914 =cut
3915
3916 sub tax_locationnum {
3917   my $self = shift;
3918   my $conf = FS::Conf->new;
3919   if ( $conf->exists('tax-pkg_address') ) {
3920     return $self->locationnum;
3921   }
3922   elsif ( $conf->exists('tax-ship_address') ) {
3923     return $self->cust_main->ship_locationnum;
3924   }
3925   else {
3926     return $self->cust_main->bill_locationnum;
3927   }
3928 }
3929
3930 =item tax_location
3931
3932 Returns the L<FS::cust_location> object for tax_locationnum.
3933
3934 =cut
3935
3936 sub tax_location {
3937   my $self = shift;
3938   my $conf = FS::Conf->new;
3939   if ( $conf->exists('tax-pkg_address') and $self->locationnum ) {
3940     return FS::cust_location->by_key($self->locationnum);
3941   }
3942   elsif ( $conf->exists('tax-ship_address') ) {
3943     return $self->cust_main->ship_location;
3944   }
3945   else {
3946     return $self->cust_main->bill_location;
3947   }
3948 }
3949
3950 =item seconds_since TIMESTAMP
3951
3952 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
3953 package have been online since TIMESTAMP, according to the session monitor.
3954
3955 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
3956 L<Time::Local> and L<Date::Parse> for conversion functions.
3957
3958 =cut
3959
3960 sub seconds_since {
3961   my($self, $since) = @_;
3962   my $seconds = 0;
3963
3964   foreach my $cust_svc (
3965     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
3966   ) {
3967     $seconds += $cust_svc->seconds_since($since);
3968   }
3969
3970   $seconds;
3971
3972 }
3973
3974 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
3975
3976 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
3977 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
3978 (exclusive).
3979
3980 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
3981 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
3982 functions.
3983
3984
3985 =cut
3986
3987 sub seconds_since_sqlradacct {
3988   my($self, $start, $end) = @_;
3989
3990   my $seconds = 0;
3991
3992   foreach my $cust_svc (
3993     grep {
3994       my $part_svc = $_->part_svc;
3995       $part_svc->svcdb eq 'svc_acct'
3996         && scalar($part_svc->part_export_usage);
3997     } $self->cust_svc
3998   ) {
3999     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
4000   }
4001
4002   $seconds;
4003
4004 }
4005
4006 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
4007
4008 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
4009 in this package for sessions ending between TIMESTAMP_START (inclusive) and
4010 TIMESTAMP_END
4011 (exclusive).
4012
4013 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
4014 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
4015 functions.
4016
4017 =cut
4018
4019 sub attribute_since_sqlradacct {
4020   my($self, $start, $end, $attrib) = @_;
4021
4022   my $sum = 0;
4023
4024   foreach my $cust_svc (
4025     grep {
4026       my $part_svc = $_->part_svc;
4027       scalar($part_svc->part_export_usage);
4028     } $self->cust_svc
4029   ) {
4030     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
4031   }
4032
4033   $sum;
4034
4035 }
4036
4037 =item quantity
4038
4039 =cut
4040
4041 sub quantity {
4042   my( $self, $value ) = @_;
4043   if ( defined($value) ) {
4044     $self->setfield('quantity', $value);
4045   }
4046   $self->getfield('quantity') || 1;
4047 }
4048
4049 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
4050
4051 Transfers as many services as possible from this package to another package.
4052
4053 The destination package can be specified by pkgnum by passing an FS::cust_pkg
4054 object.  The destination package must already exist.
4055
4056 Services are moved only if the destination allows services with the correct
4057 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
4058 this option with caution!  No provision is made for export differences
4059 between the old and new service definitions.  Probably only should be used
4060 when your exports for all service definitions of a given svcdb are identical.
4061 (attempt a transfer without it first, to move all possible svcpart-matching
4062 services)
4063
4064 Any services that can't be moved remain in the original package.
4065
4066 Returns an error, if there is one; otherwise, returns the number of services 
4067 that couldn't be moved.
4068
4069 =cut
4070
4071 sub transfer {
4072   my ($self, $dest_pkgnum, %opt) = @_;
4073
4074   my $remaining = 0;
4075   my $dest;
4076   my %target;
4077
4078   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
4079     $dest = $dest_pkgnum;
4080     $dest_pkgnum = $dest->pkgnum;
4081   } else {
4082     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
4083   }
4084
4085   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
4086
4087   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
4088     $target{$pkg_svc->svcpart} = $pkg_svc->quantity * ( $dest->quantity || 1 );
4089   }
4090
4091   foreach my $cust_svc ($dest->cust_svc) {
4092     $target{$cust_svc->svcpart}--;
4093   }
4094
4095   my %svcpart2svcparts = ();
4096   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4097     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
4098     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
4099       next if exists $svcpart2svcparts{$svcpart};
4100       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
4101       $svcpart2svcparts{$svcpart} = [
4102         map  { $_->[0] }
4103         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
4104         map {
4105               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
4106                                                    'svcpart' => $_          } );
4107               [ $_,
4108                 $pkg_svc ? $pkg_svc->primary_svc : '',
4109                 $pkg_svc ? $pkg_svc->quantity : 0,
4110               ];
4111             }
4112
4113         grep { $_ != $svcpart }
4114         map  { $_->svcpart }
4115         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
4116       ];
4117       warn "alternates for svcpart $svcpart: ".
4118            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
4119         if $DEBUG;
4120     }
4121   }
4122
4123   my $error;
4124   foreach my $cust_svc ($self->cust_svc) {
4125     my $svcnum = $cust_svc->svcnum;
4126     if($target{$cust_svc->svcpart} > 0
4127        or $FS::cust_svc::ignore_quantity) { # maybe should be a 'force' option
4128       $target{$cust_svc->svcpart}--;
4129       my $new = new FS::cust_svc { $cust_svc->hash };
4130       $new->pkgnum($dest_pkgnum);
4131       $error = $new->replace($cust_svc);
4132     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
4133       if ( $DEBUG ) {
4134         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
4135         warn "alternates to consider: ".
4136              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
4137       }
4138       my @alternate = grep {
4139                              warn "considering alternate svcpart $_: ".
4140                                   "$target{$_} available in new package\n"
4141                                if $DEBUG;
4142                              $target{$_} > 0;
4143                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
4144       if ( @alternate ) {
4145         warn "alternate(s) found\n" if $DEBUG;
4146         my $change_svcpart = $alternate[0];
4147         $target{$change_svcpart}--;
4148         my $new = new FS::cust_svc { $cust_svc->hash };
4149         $new->svcpart($change_svcpart);
4150         $new->pkgnum($dest_pkgnum);
4151         $error = $new->replace($cust_svc);
4152       } else {
4153         $remaining++;
4154       }
4155     } else {
4156       $remaining++
4157     }
4158     if ( $error ) {
4159       my @label = $cust_svc->label;
4160       return "$label[0] $label[1]: $error";
4161     }
4162   }
4163   return $remaining;
4164 }
4165
4166 =item grab_svcnums SVCNUM, SVCNUM ...
4167
4168 Change the pkgnum for the provided services to this packages.  If there is an
4169 error, returns the error, otherwise returns false.
4170
4171 =cut
4172
4173 sub grab_svcnums {
4174   my $self = shift;
4175   my @svcnum = @_;
4176
4177   my $oldAutoCommit = $FS::UID::AutoCommit;
4178   local $FS::UID::AutoCommit = 0;
4179   my $dbh = dbh;
4180
4181   foreach my $svcnum (@svcnum) {
4182     my $cust_svc = qsearchs('cust_svc', { svcnum=>$svcnum } ) or do {
4183       $dbh->rollback if $oldAutoCommit;
4184       return "unknown svcnum $svcnum";
4185     };
4186     $cust_svc->pkgnum( $self->pkgnum );
4187     my $error = $cust_svc->replace;
4188     if ( $error ) {
4189       $dbh->rollback if $oldAutoCommit;
4190       return $error;
4191     }
4192   }
4193
4194   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4195   '';
4196
4197 }
4198
4199 =item reexport
4200
4201 This method is deprecated.  See the I<depend_jobnum> option to the insert and
4202 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
4203
4204 =cut
4205
4206 #looks like this is still used by the order_pkg and change_pkg methods in
4207 # ClientAPI/MyAccount, need to look into those before removing
4208 sub reexport {
4209   my $self = shift;
4210
4211   my $oldAutoCommit = $FS::UID::AutoCommit;
4212   local $FS::UID::AutoCommit = 0;
4213   my $dbh = dbh;
4214
4215   foreach my $cust_svc ( $self->cust_svc ) {
4216     #false laziness w/svc_Common::insert
4217     my $svc_x = $cust_svc->svc_x;
4218     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
4219       my $error = $part_export->export_insert($svc_x);
4220       if ( $error ) {
4221         $dbh->rollback if $oldAutoCommit;
4222         return $error;
4223       }
4224     }
4225   }
4226
4227   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4228   '';
4229
4230 }
4231
4232 =item export_pkg_change OLD_CUST_PKG
4233
4234 Calls the "pkg_change" export action for all services attached to this package.
4235
4236 =cut
4237
4238 sub export_pkg_change {
4239   my( $self, $old )  = ( shift, shift );
4240
4241   my $oldAutoCommit = $FS::UID::AutoCommit;
4242   local $FS::UID::AutoCommit = 0;
4243   my $dbh = dbh;
4244
4245   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
4246     my $error = $svc_x->export('pkg_change', $self, $old);
4247     if ( $error ) {
4248       $dbh->rollback if $oldAutoCommit;
4249       return $error;
4250     }
4251   }
4252
4253   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4254   '';
4255
4256 }
4257
4258 =item insert_reason
4259
4260 Associates this package with a (suspension or cancellation) reason (see
4261 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
4262 L<FS::reason>).
4263
4264 Available options are:
4265
4266 =over 4
4267
4268 =item reason
4269
4270 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.
4271
4272 =item reason_otaker
4273
4274 the access_user (see L<FS::access_user>) providing the reason
4275
4276 =item date
4277
4278 a unix timestamp 
4279
4280 =item action
4281
4282 the action (cancel, susp, adjourn, expire) associated with the reason
4283
4284 =back
4285
4286 If there is an error, returns the error, otherwise returns false.
4287
4288 =cut
4289
4290 sub insert_reason {
4291   my ($self, %options) = @_;
4292
4293   my $otaker = $options{reason_otaker} ||
4294                $FS::CurrentUser::CurrentUser->username;
4295
4296   my $reasonnum;
4297   if ( $options{'reason'} =~ /^(\d+)$/ ) {
4298
4299     $reasonnum = $1;
4300
4301   } elsif ( ref($options{'reason'}) ) {
4302   
4303     return 'Enter a new reason (or select an existing one)'
4304       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
4305
4306     my $reason = new FS::reason({
4307       'reason_type' => $options{'reason'}->{'typenum'},
4308       'reason'      => $options{'reason'}->{'reason'},
4309     });
4310     my $error = $reason->insert;
4311     return $error if $error;
4312
4313     $reasonnum = $reason->reasonnum;
4314
4315   } else {
4316     return "Unparseable reason: ". $options{'reason'};
4317   }
4318
4319   my $cust_pkg_reason =
4320     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
4321                               'reasonnum' => $reasonnum, 
4322                               'otaker'    => $otaker,
4323                               'action'    => substr(uc($options{'action'}),0,1),
4324                               'date'      => $options{'date'}
4325                                                ? $options{'date'}
4326                                                : time,
4327                             });
4328
4329   $cust_pkg_reason->insert;
4330 }
4331
4332 =item insert_discount
4333
4334 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
4335 inserting a new discount on the fly (see L<FS::discount>).
4336
4337 This will look at the cust_pkg for a pseudo-field named "setup_discountnum",
4338 and if present, will create a setup discount. If the discountnum is -1,
4339 a new discount definition will be inserted using the value in
4340 "setup_discountnum_amount" or "setup_discountnum_percent". Likewise for recur.
4341
4342 If there is an error, returns the error, otherwise returns false.
4343
4344 =cut
4345
4346 sub insert_discount {
4347   #my ($self, %options) = @_;
4348   my $self = shift;
4349
4350   foreach my $x (qw(setup recur)) {
4351     if ( my $discountnum = $self->get("${x}_discountnum") ) {
4352       my $cust_pkg_discount = FS::cust_pkg_discount->new( {
4353         'pkgnum'      => $self->pkgnum,
4354         'discountnum' => $discountnum,
4355         'setuprecur'  => $x,
4356         'months_used' => 0,
4357         'end_date'    => '', #XXX
4358         #for the create a new discount case
4359         'amount'      => $self->get("${x}_discountnum_amount"),
4360         'percent'     => $self->get("${x}_discountnum_percent"),
4361         'months'      => $self->get("${x}_discountnum_months"),
4362       } );
4363       if ( $x eq 'setup' ) {
4364         $cust_pkg_discount->setup('Y');
4365         $cust_pkg_discount->months('');
4366       }
4367       my $error = $cust_pkg_discount->insert;
4368       return $error if $error;
4369     }
4370   }
4371
4372   '';
4373 }
4374
4375 =item set_usage USAGE_VALUE_HASHREF 
4376
4377 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4378 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4379 upbytes, downbytes, and totalbytes are appropriate keys.
4380
4381 All svc_accts which are part of this package have their values reset.
4382
4383 =cut
4384
4385 sub set_usage {
4386   my ($self, $valueref, %opt) = @_;
4387
4388   #only svc_acct can set_usage for now
4389   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4390     my $svc_x = $cust_svc->svc_x;
4391     $svc_x->set_usage($valueref, %opt)
4392       if $svc_x->can("set_usage");
4393   }
4394 }
4395
4396 =item recharge USAGE_VALUE_HASHREF 
4397
4398 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
4399 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
4400 upbytes, downbytes, and totalbytes are appropriate keys.
4401
4402 All svc_accts which are part of this package have their values incremented.
4403
4404 =cut
4405
4406 sub recharge {
4407   my ($self, $valueref) = @_;
4408
4409   #only svc_acct can set_usage for now
4410   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
4411     my $svc_x = $cust_svc->svc_x;
4412     $svc_x->recharge($valueref)
4413       if $svc_x->can("recharge");
4414   }
4415 }
4416
4417 =item apply_usageprice 
4418
4419 =cut
4420
4421 sub apply_usageprice {
4422   my $self = shift;
4423
4424   my $oldAutoCommit = $FS::UID::AutoCommit;
4425   local $FS::UID::AutoCommit = 0;
4426   my $dbh = dbh;
4427
4428   my $error = '';
4429
4430   foreach my $cust_pkg_usageprice ( $self->cust_pkg_usageprice ) {
4431     $error ||= $cust_pkg_usageprice->apply;
4432   }
4433
4434   if ( $error ) {
4435     $dbh->rollback if $oldAutoCommit;
4436     die "error applying part_pkg_usageprice add-ons, pkgnum ". $self->pkgnum.
4437         ": $error\n";
4438   } else {
4439     $dbh->commit if $oldAutoCommit;
4440   }
4441
4442
4443 }
4444
4445 =item cust_pkg_discount
4446
4447 =item cust_pkg_discount_active
4448
4449 =cut
4450
4451 sub cust_pkg_discount_active {
4452   my $self = shift;
4453   grep { $_->status eq 'active' } $self->cust_pkg_discount;
4454 }
4455
4456 =item cust_pkg_usage
4457
4458 Returns a list of all voice usage counters attached to this package.
4459
4460 =item apply_usage OPTIONS
4461
4462 Takes the following options:
4463 - cdr: a call detail record (L<FS::cdr>)
4464 - rate_detail: the rate determined for this call (L<FS::rate_detail>)
4465 - minutes: the maximum number of minutes to be charged
4466
4467 Finds available usage minutes for a call of this class, and subtracts
4468 up to that many minutes from the usage pool.  If the usage pool is empty,
4469 and the C<cdr-minutes_priority> global config option is set, minutes may
4470 be taken from other calls as well.  Either way, an allocation record will
4471 be created (L<FS::cdr_cust_pkg_usage>) and this method will return the 
4472 number of minutes of usage applied to the call.
4473
4474 =cut
4475
4476 sub apply_usage {
4477   my ($self, %opt) = @_;
4478   my $cdr = $opt{cdr};
4479   my $rate_detail = $opt{rate_detail};
4480   my $minutes = $opt{minutes};
4481   my $classnum = $rate_detail->classnum;
4482   my $pkgnum = $self->pkgnum;
4483   my $custnum = $self->custnum;
4484
4485   my $oldAutoCommit = $FS::UID::AutoCommit;
4486   local $FS::UID::AutoCommit = 0;
4487   my $dbh = dbh;
4488
4489   my $order = FS::Conf->new->config('cdr-minutes_priority');
4490
4491   my $is_classnum;
4492   if ( $classnum ) {
4493     $is_classnum = ' part_pkg_usage_class.classnum = '.$classnum;
4494   } else {
4495     $is_classnum = ' part_pkg_usage_class.classnum IS NULL';
4496   }
4497   my @usage_recs = qsearch({
4498       'table'     => 'cust_pkg_usage',
4499       'addl_from' => ' JOIN part_pkg_usage       USING (pkgusagepart)'.
4500                      ' JOIN cust_pkg             USING (pkgnum)'.
4501                      ' JOIN part_pkg_usage_class USING (pkgusagepart)',
4502       'select'    => 'cust_pkg_usage.*',
4503       'extra_sql' => " WHERE ( cust_pkg.pkgnum = $pkgnum OR ".
4504                      " ( cust_pkg.custnum = $custnum AND ".
4505                      " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4506                      $is_classnum . ' AND '.
4507                      " cust_pkg_usage.minutes > 0",
4508       'order_by'  => " ORDER BY priority ASC",
4509   });
4510
4511   my $orig_minutes = $minutes;
4512   my $error;
4513   while (!$error and $minutes > 0 and @usage_recs) {
4514     my $cust_pkg_usage = shift @usage_recs;
4515     $cust_pkg_usage->select_for_update;
4516     my $cdr_cust_pkg_usage = FS::cdr_cust_pkg_usage->new({
4517         pkgusagenum => $cust_pkg_usage->pkgusagenum,
4518         acctid      => $cdr->acctid,
4519         minutes     => min($cust_pkg_usage->minutes, $minutes),
4520     });
4521     $cust_pkg_usage->set('minutes',
4522       $cust_pkg_usage->minutes - $cdr_cust_pkg_usage->minutes
4523     );
4524     $error = $cust_pkg_usage->replace || $cdr_cust_pkg_usage->insert;
4525     $minutes -= $cdr_cust_pkg_usage->minutes;
4526   }
4527   if ( $order and $minutes > 0 and !$error ) {
4528     # then try to steal minutes from another call
4529     my %search = (
4530         'table'     => 'cdr_cust_pkg_usage',
4531         'addl_from' => ' JOIN cust_pkg_usage        USING (pkgusagenum)'.
4532                        ' JOIN part_pkg_usage        USING (pkgusagepart)'.
4533                        ' JOIN cust_pkg              USING (pkgnum)'.
4534                        ' JOIN part_pkg_usage_class  USING (pkgusagepart)'.
4535                        ' JOIN cdr                   USING (acctid)',
4536         'select'    => 'cdr_cust_pkg_usage.*',
4537         'extra_sql' => " WHERE cdr.freesidestatus = 'rated' AND ".
4538                        " ( cust_pkg.pkgnum = $pkgnum OR ".
4539                        " ( cust_pkg.custnum = $custnum AND ".
4540                        " part_pkg_usage.shared IS NOT NULL ) ) AND ".
4541                        " part_pkg_usage_class.classnum = $classnum",
4542         'order_by'  => ' ORDER BY part_pkg_usage.priority ASC',
4543     );
4544     if ( $order eq 'time' ) {
4545       # find CDRs that are using minutes, but have a later startdate
4546       # than this call
4547       my $startdate = $cdr->startdate;
4548       if ($startdate !~ /^\d+$/) {
4549         die "bad cdr startdate '$startdate'";
4550       }
4551       $search{'extra_sql'} .= " AND cdr.startdate > $startdate";
4552       # minimize needless reshuffling
4553       $search{'order_by'} .= ', cdr.startdate DESC';
4554     } else {
4555       # XXX may not work correctly with rate_time schedules.  Could 
4556       # fix this by storing ratedetailnum in cdr_cust_pkg_usage, I 
4557       # think...
4558       $search{'addl_from'} .=
4559         ' JOIN rate_detail'.
4560         ' ON (cdr.rated_ratedetailnum = rate_detail.ratedetailnum)';
4561       if ( $order eq 'rate_high' ) {
4562         $search{'extra_sql'} .= ' AND rate_detail.min_charge < '.
4563                                 $rate_detail->min_charge;
4564         $search{'order_by'} .= ', rate_detail.min_charge ASC';
4565       } elsif ( $order eq 'rate_low' ) {
4566         $search{'extra_sql'} .= ' AND rate_detail.min_charge > '.
4567                                 $rate_detail->min_charge;
4568         $search{'order_by'} .= ', rate_detail.min_charge DESC';
4569       } else {
4570         #  this should really never happen
4571         die "invalid cdr-minutes_priority value '$order'\n";
4572       }
4573     }
4574     my @cdr_usage_recs = qsearch(\%search);
4575     my %reproc_cdrs;
4576     while (!$error and @cdr_usage_recs and $minutes > 0) {
4577       my $cdr_cust_pkg_usage = shift @cdr_usage_recs;
4578       my $cust_pkg_usage = $cdr_cust_pkg_usage->cust_pkg_usage;
4579       my $old_cdr = $cdr_cust_pkg_usage->cdr;
4580       $reproc_cdrs{$old_cdr->acctid} = $old_cdr;
4581       $cdr_cust_pkg_usage->select_for_update;
4582       $old_cdr->select_for_update;
4583       $cust_pkg_usage->select_for_update;
4584       # in case someone else stole the usage from this CDR
4585       # while waiting for the lock...
4586       next if $old_cdr->acctid != $cdr_cust_pkg_usage->acctid;
4587       # steal the usage allocation and flag the old CDR for reprocessing
4588       $cdr_cust_pkg_usage->set('acctid', $cdr->acctid);
4589       # if the allocation is more minutes than we need, adjust it...
4590       my $delta = $cdr_cust_pkg_usage->minutes - $minutes;
4591       if ( $delta > 0 ) {
4592         $cdr_cust_pkg_usage->set('minutes', $minutes);
4593         $cust_pkg_usage->set('minutes', $cust_pkg_usage->minutes + $delta);
4594         $error = $cust_pkg_usage->replace;
4595       }
4596       #warn 'CDR '.$cdr->acctid . ' stealing allocation '.$cdr_cust_pkg_usage->cdrusagenum.' from CDR '.$old_cdr->acctid."\n";
4597       $error ||= $cdr_cust_pkg_usage->replace;
4598       # deduct the stolen minutes
4599       $minutes -= $cdr_cust_pkg_usage->minutes;
4600     }
4601     # after all minute-stealing is done, reset the affected CDRs
4602     foreach (values %reproc_cdrs) {
4603       $error ||= $_->set_status('');
4604       # XXX or should we just call $cdr->rate right here?
4605       # it's not like we can create a loop this way, since the min_charge
4606       # or call time has to go monotonically in one direction.
4607       # we COULD get some very deep recursions going, though...
4608     }
4609   } # if $order and $minutes
4610   if ( $error ) {
4611     $dbh->rollback;
4612     die "error applying included minutes\npkgnum ".$self->pkgnum.", class $classnum, acctid ".$cdr->acctid."\n$error\n"
4613   } else {
4614     $dbh->commit if $oldAutoCommit;
4615     return $orig_minutes - $minutes;
4616   }
4617 }
4618
4619 =item supplemental_pkgs
4620
4621 Returns a list of all packages supplemental to this one.
4622
4623 =cut
4624
4625 sub supplemental_pkgs {
4626   my $self = shift;
4627   qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
4628 }
4629
4630 =item main_pkg
4631
4632 Returns the package that this one is supplemental to, if any.
4633
4634 =cut
4635
4636 sub main_pkg {
4637   my $self = shift;
4638   if ( $self->main_pkgnum ) {
4639     return FS::cust_pkg->by_key($self->main_pkgnum);
4640   }
4641   return;
4642 }
4643
4644 =back
4645
4646 =head1 CLASS METHODS
4647
4648 =over 4
4649
4650 =item recurring_sql
4651
4652 Returns an SQL expression identifying recurring packages.
4653
4654 =cut
4655
4656 sub recurring_sql { "
4657   '0' != ( select freq from part_pkg
4658              where cust_pkg.pkgpart = part_pkg.pkgpart )
4659 "; }
4660
4661 =item onetime_sql
4662
4663 Returns an SQL expression identifying one-time packages.
4664
4665 =cut
4666
4667 sub onetime_sql { "
4668   '0' = ( select freq from part_pkg
4669             where cust_pkg.pkgpart = part_pkg.pkgpart )
4670 "; }
4671
4672 =item ordered_sql
4673
4674 Returns an SQL expression identifying ordered packages (recurring packages not
4675 yet billed).
4676
4677 =cut
4678
4679 sub ordered_sql {
4680    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
4681 }
4682
4683 =item active_sql
4684
4685 Returns an SQL expression identifying active packages.
4686
4687 =cut
4688
4689 sub active_sql {
4690   $_[0]->recurring_sql. "
4691   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4692   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4693   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4694 "; }
4695
4696 =item not_yet_billed_sql
4697
4698 Returns an SQL expression identifying packages which have not yet been billed.
4699
4700 =cut
4701
4702 sub not_yet_billed_sql { "
4703       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
4704   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4705   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4706 "; }
4707
4708 =item inactive_sql
4709
4710 Returns an SQL expression identifying inactive packages (one-time packages
4711 that are otherwise unsuspended/uncancelled).
4712
4713 =cut
4714
4715 sub inactive_sql { "
4716   ". $_[0]->onetime_sql(). "
4717   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
4718   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
4719   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
4720 "; }
4721
4722 =item on_hold_sql
4723
4724 Returns an SQL expression identifying on-hold packages.
4725
4726 =cut
4727
4728 sub on_hold_sql {
4729   #$_[0]->recurring_sql(). ' AND '.
4730   "
4731         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel  = 0 )
4732     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp   != 0
4733     AND ( cust_pkg.setup  IS     NULL  OR cust_pkg.setup   = 0 )
4734   ";
4735 }
4736
4737 =item susp_sql
4738 =item suspended_sql
4739
4740 Returns an SQL expression identifying suspended packages.
4741
4742 =cut
4743
4744 sub suspended_sql { susp_sql(@_); }
4745 sub susp_sql {
4746   #$_[0]->recurring_sql(). ' AND '.
4747   "
4748         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
4749     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
4750     AND   cust_pkg.setup  IS NOT NULL AND cust_pkg.setup != 0
4751   ";
4752 }
4753
4754 =item cancel_sql
4755 =item cancelled_sql
4756
4757 Returns an SQL exprression identifying cancelled packages.
4758
4759 =cut
4760
4761 sub cancelled_sql { cancel_sql(@_); }
4762 sub cancel_sql { 
4763   #$_[0]->recurring_sql(). ' AND '.
4764   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
4765 }
4766
4767 =item status_sql
4768
4769 Returns an SQL expression to give the package status as a string.
4770
4771 =cut
4772
4773 sub status_sql {
4774 "CASE
4775   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
4776   WHEN ( cust_pkg.susp IS NOT NULL AND cust_pkg.setup IS NULL ) THEN 'on hold'
4777   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
4778   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
4779   WHEN ".onetime_sql()." THEN 'one-time charge'
4780   ELSE 'active'
4781 END"
4782 }
4783
4784 =item fcc_477_count
4785
4786 Returns a list of two package counts.  The first is a count of packages
4787 based on the supplied criteria and the second is the count of residential
4788 packages with those same criteria.  Criteria are specified as in the search
4789 method.
4790
4791 =cut
4792
4793 sub fcc_477_count {
4794   my ($class, $params) = @_;
4795
4796   my $sql_query = $class->search( $params );
4797
4798   my $count_sql = delete($sql_query->{'count_query'});
4799   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
4800     or die "couldn't parse count_sql";
4801
4802   my $count_sth = dbh->prepare($count_sql)
4803     or die "Error preparing $count_sql: ". dbh->errstr;
4804   $count_sth->execute
4805     or die "Error executing $count_sql: ". $count_sth->errstr;
4806   my $count_arrayref = $count_sth->fetchrow_arrayref;
4807
4808   return ( @$count_arrayref );
4809
4810 }
4811
4812 =item tax_locationnum_sql
4813
4814 Returns an SQL expression for the tax location for a package, based
4815 on the settings of 'tax-pkg_address' and 'tax-ship_address'.
4816
4817 =cut
4818
4819 sub tax_locationnum_sql {
4820   my $conf = FS::Conf->new;
4821   if ( $conf->exists('tax-pkg_address') ) {
4822     'cust_pkg.locationnum';
4823   }
4824   elsif ( $conf->exists('tax-ship_address') ) {
4825     'cust_main.ship_locationnum';
4826   }
4827   else {
4828     'cust_main.bill_locationnum';
4829   }
4830 }
4831
4832 =item location_sql
4833
4834 Returns a list: the first item is an SQL fragment identifying matching 
4835 packages/customers via location (taking into account shipping and package
4836 address taxation, if enabled), and subsequent items are the parameters to
4837 substitute for the placeholders in that fragment.
4838
4839 =cut
4840
4841 sub location_sql {
4842   my($class, %opt) = @_;
4843   my $ornull = $opt{'ornull'};
4844
4845   my $conf = new FS::Conf;
4846
4847   # '?' placeholders in _location_sql_where
4848   my $x = $ornull ? 3 : 2;
4849   my @bill_param = ( 
4850     ('district')x3,
4851     ('city')x3, 
4852     ('county')x$x,
4853     ('state')x$x,
4854     'country'
4855   );
4856
4857   my $main_where;
4858   my @main_param;
4859   if ( $conf->exists('tax-ship_address') ) {
4860
4861     $main_where = "(
4862          (     ( ship_last IS NULL     OR  ship_last  = '' )
4863            AND ". _location_sql_where('cust_main', '', $ornull ). "
4864          )
4865       OR (       ship_last IS NOT NULL AND ship_last != ''
4866            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
4867          )
4868     )";
4869     #    AND payby != 'COMP'
4870
4871     @main_param = ( @bill_param, @bill_param );
4872
4873   } else {
4874
4875     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
4876     @main_param = @bill_param;
4877
4878   }
4879
4880   my $where;
4881   my @param;
4882   if ( $conf->exists('tax-pkg_address') ) {
4883
4884     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
4885
4886     $where = " (
4887                     ( cust_pkg.locationnum IS     NULL AND $main_where )
4888                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
4889                )
4890              ";
4891     @param = ( @main_param, @bill_param );
4892   
4893   } else {
4894
4895     $where = $main_where;
4896     @param = @main_param;
4897
4898   }
4899
4900   ( $where, @param );
4901
4902 }
4903
4904 #subroutine, helper for location_sql
4905 sub _location_sql_where {
4906   my $table  = shift;
4907   my $prefix = @_ ? shift : '';
4908   my $ornull = @_ ? shift : '';
4909
4910 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
4911
4912   $ornull = $ornull ? ' OR ? IS NULL ' : '';
4913
4914   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
4915   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
4916   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
4917
4918   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
4919
4920 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
4921   "
4922         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4923     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
4924     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
4925     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
4926     AND   $table.${prefix}country  = ?
4927   ";
4928 }
4929
4930 sub _X_show_zero {
4931   my( $self, $what ) = @_;
4932
4933   my $what_show_zero = $what. '_show_zero';
4934   length($self->$what_show_zero())
4935     ? ($self->$what_show_zero() eq 'Y')
4936     : $self->part_pkg->$what_show_zero();
4937 }
4938
4939 =head1 SUBROUTINES
4940
4941 =over 4
4942
4943 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
4944
4945 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
4946 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
4947
4948 CUSTNUM is a customer (see L<FS::cust_main>)
4949
4950 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
4951 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
4952 permitted.
4953
4954 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
4955 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
4956 new billing items.  An error is returned if this is not possible (see
4957 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
4958 parameter.
4959
4960 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
4961 newly-created cust_pkg objects.
4962
4963 REFNUM, if specified, will specify the FS::pkg_referral record to be created
4964 and inserted.  Multiple FS::pkg_referral records can be created by
4965 setting I<refnum> to an array reference of refnums or a hash reference with
4966 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
4967 record will be created corresponding to cust_main.refnum.
4968
4969 =cut
4970
4971 sub order {
4972   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
4973
4974   my $conf = new FS::Conf;
4975
4976   # Transactionize this whole mess
4977   my $oldAutoCommit = $FS::UID::AutoCommit;
4978   local $FS::UID::AutoCommit = 0;
4979   my $dbh = dbh;
4980
4981   my $error;
4982 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
4983 #  return "Customer not found: $custnum" unless $cust_main;
4984
4985   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
4986     if $DEBUG;
4987
4988   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
4989                          @$remove_pkgnum;
4990
4991   my $change = scalar(@old_cust_pkg) != 0;
4992
4993   my %hash = (); 
4994   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
4995
4996     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
4997          " to pkgpart ". $pkgparts->[0]. "\n"
4998       if $DEBUG;
4999
5000     my $err_or_cust_pkg =
5001       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
5002                                 'refnum'  => $refnum,
5003                               );
5004
5005     unless (ref($err_or_cust_pkg)) {
5006       $dbh->rollback if $oldAutoCommit;
5007       return $err_or_cust_pkg;
5008     }
5009
5010     push @$return_cust_pkg, $err_or_cust_pkg;
5011     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5012     return '';
5013
5014   }
5015
5016   # Create the new packages.
5017   foreach my $pkgpart (@$pkgparts) {
5018
5019     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
5020
5021     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
5022                                       pkgpart => $pkgpart,
5023                                       refnum  => $refnum,
5024                                       %hash,
5025                                     };
5026     $error = $cust_pkg->insert( 'change' => $change );
5027     push @$return_cust_pkg, $cust_pkg;
5028
5029     foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
5030       my $supp_pkg = FS::cust_pkg->new({
5031           custnum => $custnum,
5032           pkgpart => $link->dst_pkgpart,
5033           refnum  => $refnum,
5034           main_pkgnum => $cust_pkg->pkgnum,
5035           %hash,
5036       });
5037       $error ||= $supp_pkg->insert( 'change' => $change );
5038       push @$return_cust_pkg, $supp_pkg;
5039     }
5040
5041     if ($error) {
5042       $dbh->rollback if $oldAutoCommit;
5043       return $error;
5044     }
5045
5046   }
5047   # $return_cust_pkg now contains refs to all of the newly 
5048   # created packages.
5049
5050   # Transfer services and cancel old packages.
5051   foreach my $old_pkg (@old_cust_pkg) {
5052
5053     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
5054       if $DEBUG;
5055
5056     foreach my $new_pkg (@$return_cust_pkg) {
5057       $error = $old_pkg->transfer($new_pkg);
5058       if ($error and $error == 0) {
5059         # $old_pkg->transfer failed.
5060         $dbh->rollback if $oldAutoCommit;
5061         return $error;
5062       }
5063     }
5064
5065     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
5066       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
5067       foreach my $new_pkg (@$return_cust_pkg) {
5068         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
5069         if ($error and $error == 0) {
5070           # $old_pkg->transfer failed.
5071         $dbh->rollback if $oldAutoCommit;
5072         return $error;
5073         }
5074       }
5075     }
5076
5077     if ($error > 0) {
5078       # Transfers were successful, but we went through all of the 
5079       # new packages and still had services left on the old package.
5080       # We can't cancel the package under the circumstances, so abort.
5081       $dbh->rollback if $oldAutoCommit;
5082       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
5083     }
5084     $error = $old_pkg->cancel( quiet=>1, 'no_delay_cancel'=>1 );
5085     if ($error) {
5086       $dbh->rollback;
5087       return $error;
5088     }
5089   }
5090   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5091   '';
5092 }
5093
5094 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
5095
5096 A bulk change method to change packages for multiple customers.
5097
5098 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
5099 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
5100 permitted.
5101
5102 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
5103 replace.  The services (see L<FS::cust_svc>) are moved to the
5104 new billing items.  An error is returned if this is not possible (see
5105 L<FS::pkg_svc>).
5106
5107 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
5108 newly-created cust_pkg objects.
5109
5110 =cut
5111
5112 sub bulk_change {
5113   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
5114
5115   # Transactionize this whole mess
5116   my $oldAutoCommit = $FS::UID::AutoCommit;
5117   local $FS::UID::AutoCommit = 0;
5118   my $dbh = dbh;
5119
5120   my @errors;
5121   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
5122                          @$remove_pkgnum;
5123
5124   while(scalar(@old_cust_pkg)) {
5125     my @return = ();
5126     my $custnum = $old_cust_pkg[0]->custnum;
5127     my (@remove) = map { $_->pkgnum }
5128                    grep { $_->custnum == $custnum } @old_cust_pkg;
5129     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
5130
5131     my $error = order $custnum, $pkgparts, \@remove, \@return;
5132
5133     push @errors, $error
5134       if $error;
5135     push @$return_cust_pkg, @return;
5136   }
5137
5138   if (scalar(@errors)) {
5139     $dbh->rollback if $oldAutoCommit;
5140     return join(' / ', @errors);
5141   }
5142
5143   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
5144   '';
5145 }
5146
5147 =item forward_emails
5148
5149 Returns a hash of svcnums and corresponding email addresses
5150 for svc_acct services that can be used as source or dest
5151 for svc_forward services provisioned in this package.
5152
5153 Accepts options I<svc_forward> OR I<svcnum> for a svc_forward
5154 service;  if included, will ensure the current values of the
5155 specified service are included in the list, even if for some
5156 other reason they wouldn't be.  If called as a class method
5157 with a specified service, returns only these current values.
5158
5159 Caution: does not actually check if svc_forward services are
5160 available to be provisioned on this package.
5161
5162 =cut
5163
5164 sub forward_emails {
5165   my $self = shift;
5166   my %opt = @_;
5167
5168   #load optional service, thoroughly validated
5169   die "Use svcnum or svc_forward, not both"
5170     if $opt{'svcnum'} && $opt{'svc_forward'};
5171   my $svc_forward = $opt{'svc_forward'};
5172   $svc_forward ||= qsearchs('svc_forward',{ 'svcnum' => $opt{'svcnum'} })
5173     if $opt{'svcnum'};
5174   die "Specified service is not a forward service"
5175     if $svc_forward && (ref($svc_forward) ne 'FS::svc_forward');
5176   die "Specified service not found"
5177     if ($opt{'svcnum'} || $opt{'svc_forward'}) && !$svc_forward;
5178
5179   my %email;
5180
5181   ## everything below was basically copied from httemplate/edit/svc_forward.cgi 
5182   ## with minimal refactoring, not sure why we can't just load all svc_accts for this custnum
5183
5184   #add current values from specified service, if there was one
5185   if ($svc_forward) {
5186     foreach my $method (qw( srcsvc_acct dstsvc_acct )) {
5187       my $svc_acct = $svc_forward->$method();
5188       $email{$svc_acct->svcnum} = $svc_acct->email if $svc_acct;
5189     }
5190   }
5191
5192   if (ref($self) eq 'FS::cust_pkg') {
5193
5194     #and including the rest for this customer
5195     my($u_part_svc,@u_acct_svcparts);
5196     foreach $u_part_svc ( qsearch('part_svc',{'svcdb'=>'svc_acct'}) ) {
5197       push @u_acct_svcparts,$u_part_svc->getfield('svcpart');
5198     }
5199
5200     my $custnum = $self->getfield('custnum');
5201     foreach my $i_cust_pkg ( qsearch('cust_pkg',{'custnum'=>$custnum}) ) {
5202       my $cust_pkgnum = $i_cust_pkg->getfield('pkgnum');
5203       #now find the corresponding record(s) in cust_svc (for this pkgnum!)
5204       foreach my $acct_svcpart (@u_acct_svcparts) {
5205         foreach my $i_cust_svc (
5206           qsearch( 'cust_svc', { 'pkgnum'  => $cust_pkgnum,
5207                                  'svcpart' => $acct_svcpart } )
5208         ) {
5209           my $svc_acct = qsearchs( 'svc_acct', { 'svcnum' => $i_cust_svc->svcnum } );
5210           $email{$svc_acct->svcnum} = $svc_acct->email;
5211         }  
5212       }
5213     }
5214   }
5215
5216   return %email;
5217 }
5218
5219 # Used by FS::Upgrade to migrate to a new database.
5220 sub _upgrade_data {  # class method
5221   my ($class, %opts) = @_;
5222   $class->_upgrade_otaker(%opts);
5223   my @statements = (
5224     # RT#10139, bug resulting in contract_end being set when it shouldn't
5225   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
5226     # RT#10830, bad calculation of prorate date near end of year
5227     # the date range for bill is December 2009, and we move it forward
5228     # one year if it's before the previous bill date (which it should 
5229     # never be)
5230   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
5231   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
5232   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
5233     # RT6628, add order_date to cust_pkg
5234     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
5235         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
5236         history_action = \'insert\') where order_date is null',
5237   );
5238   foreach my $sql (@statements) {
5239     my $sth = dbh->prepare($sql);
5240     $sth->execute or die $sth->errstr;
5241   }
5242
5243   # RT31194: supplemental package links that are deleted don't clean up 
5244   # linked records
5245   my @pkglinknums = qsearch({
5246       'select'    => 'DISTINCT cust_pkg.pkglinknum',
5247       'table'     => 'cust_pkg',
5248       'addl_from' => ' LEFT JOIN part_pkg_link USING (pkglinknum) ',
5249       'extra_sql' => ' WHERE cust_pkg.pkglinknum IS NOT NULL 
5250                         AND part_pkg_link.pkglinknum IS NULL',
5251   });
5252   foreach (@pkglinknums) {
5253     my $pkglinknum = $_->pkglinknum;
5254     warn "cleaning part_pkg_link #$pkglinknum\n";
5255     my $part_pkg_link = FS::part_pkg_link->new({pkglinknum => $pkglinknum});
5256     my $error = $part_pkg_link->remove_linked;
5257     die $error if $error;
5258   }
5259 }
5260
5261 =back
5262
5263 =head1 BUGS
5264
5265 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
5266
5267 In sub order, the @pkgparts array (passed by reference) is clobbered.
5268
5269 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
5270 method to pass dates to the recur_prog expression, it should do so.
5271
5272 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
5273 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
5274 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
5275 configuration values.  Probably need a subroutine which decides what to do
5276 based on whether or not we've fetched the user yet, rather than a hash.  See
5277 FS::UID and the TODO.
5278
5279 Now that things are transactional should the check in the insert method be
5280 moved to check ?
5281
5282 =head1 SEE ALSO
5283
5284 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
5285 L<FS::pkg_svc>, schema.html from the base documentation
5286
5287 =cut
5288
5289 1;
5290