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