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