740a562749ebc841c549e5dbcee553c1b429d327
[freeside.git] / FS / FS / cust_pkg.pm
1 package FS::cust_pkg;
2
3 use strict;
4 use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::location_Mixin
5              FS::m2m_Common FS::option_Common );
6 use vars qw($disable_agentcheck $DEBUG $me);
7 use Carp qw(cluck);
8 use Scalar::Util qw( blessed );
9 use List::Util qw(max);
10 use Tie::IxHash;
11 use Time::Local qw( timelocal timelocal_nocheck );
12 use MIME::Entity;
13 use FS::UID qw( getotaker dbh driver_name );
14 use FS::Misc qw( send_email );
15 use FS::Record qw( qsearch qsearchs fields );
16 use FS::CurrentUser;
17 use FS::cust_svc;
18 use FS::part_pkg;
19 use FS::cust_main;
20 use FS::cust_location;
21 use FS::pkg_svc;
22 use FS::cust_bill_pkg;
23 use FS::cust_pkg_detail;
24 use FS::cust_event;
25 use FS::h_cust_svc;
26 use FS::reg_code;
27 use FS::part_svc;
28 use FS::cust_pkg_reason;
29 use FS::reason;
30 use FS::cust_pkg_discount;
31 use FS::discount;
32 use FS::UI::Web;
33 use Data::Dumper;
34
35 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
36 # setup }
37 # because they load configuration by setting FS::UID::callback (see TODO)
38 use FS::svc_acct;
39 use FS::svc_domain;
40 use FS::svc_www;
41 use FS::svc_forward;
42
43 # for sending cancel emails in sub cancel
44 use FS::Conf;
45
46 $DEBUG = 0;
47 $me = '[FS::cust_pkg]';
48
49 $disable_agentcheck = 0;
50
51 sub _cache {
52   my $self = shift;
53   my ( $hashref, $cache ) = @_;
54   #if ( $hashref->{'pkgpart'} ) {
55   if ( $hashref->{'pkg'} ) {
56     # #@{ $self->{'_pkgnum'} } = ();
57     # my $subcache = $cache->subcache('pkgpart', 'part_pkg');
58     # $self->{'_pkgpart'} = $subcache;
59     # #push @{ $self->{'_pkgnum'} },
60     #   FS::part_pkg->new_or_cached($hashref, $subcache);
61     $self->{'_pkgpart'} = FS::part_pkg->new($hashref);
62   }
63   if ( exists $hashref->{'svcnum'} ) {
64     #@{ $self->{'_pkgnum'} } = ();
65     my $subcache = $cache->subcache('svcnum', 'cust_svc', $hashref->{pkgnum});
66     $self->{'_svcnum'} = $subcache;
67     #push @{ $self->{'_pkgnum'} },
68     FS::cust_svc->new_or_cached($hashref, $subcache) if $hashref->{svcnum};
69   }
70 }
71
72 =head1 NAME
73
74 FS::cust_pkg - Object methods for cust_pkg objects
75
76 =head1 SYNOPSIS
77
78   use FS::cust_pkg;
79
80   $record = new FS::cust_pkg \%hash;
81   $record = new FS::cust_pkg { 'column' => 'value' };
82
83   $error = $record->insert;
84
85   $error = $new_record->replace($old_record);
86
87   $error = $record->delete;
88
89   $error = $record->check;
90
91   $error = $record->cancel;
92
93   $error = $record->suspend;
94
95   $error = $record->unsuspend;
96
97   $part_pkg = $record->part_pkg;
98
99   @labels = $record->labels;
100
101   $seconds = $record->seconds_since($timestamp);
102
103   $error = FS::cust_pkg::order( $custnum, \@pkgparts );
104   $error = FS::cust_pkg::order( $custnum, \@pkgparts, \@remove_pkgnums ] );
105
106 =head1 DESCRIPTION
107
108 An FS::cust_pkg object represents a customer billing item.  FS::cust_pkg
109 inherits from FS::Record.  The following fields are currently supported:
110
111 =over 4
112
113 =item pkgnum
114
115 Primary key (assigned automatically for new billing items)
116
117 =item custnum
118
119 Customer (see L<FS::cust_main>)
120
121 =item pkgpart
122
123 Billing item definition (see L<FS::part_pkg>)
124
125 =item locationnum
126
127 Optional link to package location (see L<FS::location>)
128
129 =item order_date
130
131 date package was ordered (also remains same on changes)
132
133 =item start_date
134
135 date
136
137 =item setup
138
139 date
140
141 =item bill
142
143 date (next bill date)
144
145 =item last_bill
146
147 last bill date
148
149 =item adjourn
150
151 date
152
153 =item susp
154
155 date
156
157 =item expire
158
159 date
160
161 =item contract_end
162
163 date
164
165 =item cancel
166
167 date
168
169 =item usernum
170
171 order taker (see L<FS::access_user>)
172
173 =item manual_flag
174
175 If this field is set to 1, disables the automatic
176 unsuspension of this package when using the B<unsuspendauto> config option.
177
178 =item quantity
179
180 If not set, defaults to 1
181
182 =item change_date
183
184 Date of change from previous package
185
186 =item change_pkgnum
187
188 Previous pkgnum
189
190 =item change_pkgpart
191
192 Previous pkgpart
193
194 =item change_locationnum
195
196 Previous locationnum
197
198 =item waive_setup
199
200 =back
201
202 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
203 are specified as UNIX timestamps; see L<perlfunc/"time">.  Also see
204 L<Time::Local> and L<Date::Parse> for conversion functions.
205
206 =head1 METHODS
207
208 =over 4
209
210 =item new HASHREF
211
212 Create a new billing item.  To add the item to the database, see L<"insert">.
213
214 =cut
215
216 sub table { 'cust_pkg'; }
217 sub cust_linked { $_[0]->cust_main_custnum; } 
218 sub cust_unlinked_msg {
219   my $self = shift;
220   "WARNING: can't find cust_main.custnum ". $self->custnum.
221   ' (cust_pkg.pkgnum '. $self->pkgnum. ')';
222 }
223
224 =item insert [ OPTION => VALUE ... ]
225
226 Adds this billing item to the database ("Orders" the item).  If there is an
227 error, returns the error, otherwise returns false.
228
229 If the additional field I<promo_code> is defined instead of I<pkgpart>, it
230 will be used to look up the package definition and agent restrictions will be
231 ignored.
232
233 If the additional field I<refnum> is defined, an FS::pkg_referral record will
234 be created and inserted.  Multiple FS::pkg_referral records can be created by
235 setting I<refnum> to an array reference of refnums or a hash reference with
236 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
237 record will be created corresponding to cust_main.refnum.
238
239 The following options are available:
240
241 =over 4
242
243 =item change
244
245 If set true, supresses actions that should only be taken for new package
246 orders.  (Currently this includes: intro periods when delay_setup is on.)
247
248 =item options
249
250 cust_pkg_option records will be created
251
252 =item ticket_subject
253
254 a ticket will be added to this customer with this subject
255
256 =item ticket_queue
257
258 an optional queue name for ticket additions
259
260 =back
261
262 =cut
263
264 sub insert {
265   my( $self, %options ) = @_;
266
267   my $error = $self->check_pkgpart;
268   return $error if $error;
269
270   my $part_pkg = $self->part_pkg;
271
272   # if the package def says to start only on the first of the month:
273   if ( $part_pkg->option('start_1st', 1) && !$self->start_date ) {
274     my ($sec,$min,$hour,$mday,$mon,$year) = (localtime(time) )[0,1,2,3,4,5];
275     $mon += 1 unless $mday == 1;
276     until ( $mon < 12 ) { $mon -= 12; $year++; }
277     $self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
278   }
279
280   # set up any automatic expire/adjourn/contract_end timers
281   # based on the start date
282   foreach my $action ( qw(expire adjourn contract_end) ) {
283     my $months = $part_pkg->option("${action}_months",1);
284     if($months and !$self->$action) {
285       my $start = $self->start_date || $self->setup || time;
286       $self->$action( $part_pkg->add_freq($start, $months) );
287     }
288   }
289
290   # if this package has "free days" and delayed setup fee, tehn 
291   # set start date that many days in the future.
292   # (this should have been set in the UI, but enforce it here)
293   if (    ! $options{'change'}
294        && ( my $free_days = $part_pkg->option('free_days',1) )
295        && $part_pkg->option('delay_setup',1)
296        #&& ! $self->start_date
297      )
298   {
299     $self->start_date( $part_pkg->default_start_date );
300   }
301
302   $self->order_date(time);
303
304   local $SIG{HUP} = 'IGNORE';
305   local $SIG{INT} = 'IGNORE';
306   local $SIG{QUIT} = 'IGNORE';
307   local $SIG{TERM} = 'IGNORE';
308   local $SIG{TSTP} = 'IGNORE';
309   local $SIG{PIPE} = 'IGNORE';
310
311   my $oldAutoCommit = $FS::UID::AutoCommit;
312   local $FS::UID::AutoCommit = 0;
313   my $dbh = dbh;
314
315   $error = $self->SUPER::insert($options{options} ? %{$options{options}} : ());
316   if ( $error ) {
317     $dbh->rollback if $oldAutoCommit;
318     return $error;
319   }
320
321   $self->refnum($self->cust_main->refnum) unless $self->refnum;
322   $self->refnum( [ $self->refnum ] ) unless ref($self->refnum);
323   $self->process_m2m( 'link_table'   => 'pkg_referral',
324                       'target_table' => 'part_referral',
325                       'params'       => $self->refnum,
326                     );
327
328   if ( $self->discountnum ) {
329     my $error = $self->insert_discount();
330     if ( $error ) {
331       $dbh->rollback if $oldAutoCommit;
332       return $error;
333     }
334   }
335
336   #if ( $self->reg_code ) {
337   #  my $reg_code = qsearchs('reg_code', { 'code' => $self->reg_code } );
338   #  $error = $reg_code->delete;
339   #  if ( $error ) {
340   #    $dbh->rollback if $oldAutoCommit;
341   #    return $error;
342   #  }
343   #}
344
345   my $conf = new FS::Conf;
346
347   if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
348
349     #eval '
350     #  use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
351     #  use RT;
352     #';
353     #die $@ if $@;
354     #
355     #RT::LoadConfig();
356     #RT::Init();
357     use FS::TicketSystem;
358     FS::TicketSystem->init();
359
360     my $q = new RT::Queue($RT::SystemUser);
361     $q->Load($options{ticket_queue}) if $options{ticket_queue};
362     my $t = new RT::Ticket($RT::SystemUser);
363     my $mime = new MIME::Entity;
364     $mime->build( Type => 'text/plain', Data => $options{ticket_subject} );
365     $t->Create( $options{ticket_queue} ? (Queue => $q) : (),
366                 Subject => $options{ticket_subject},
367                 MIMEObj => $mime,
368               );
369     $t->AddLink( Type   => 'MemberOf',
370                  Target => 'freeside://freeside/cust_main/'. $self->custnum,
371                );
372   }
373
374   if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) {
375     my $queue = new FS::queue {
376       'job'     => 'FS::cust_main::queueable_print',
377     };
378     $error = $queue->insert(
379       'custnum'  => $self->custnum,
380       'template' => 'welcome_letter',
381     );
382
383     if ($error) {
384       warn "can't send welcome letter: $error";
385     }
386
387   }
388
389   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
390   '';
391
392 }
393
394 =item delete
395
396 This method now works but you probably shouldn't use it.
397
398 You don't want to delete packages, because there would then be no record
399 the customer ever purchased the package.  Instead, see the cancel method and
400 hide cancelled packages.
401
402 =cut
403
404 sub delete {
405   my $self = shift;
406
407   local $SIG{HUP} = 'IGNORE';
408   local $SIG{INT} = 'IGNORE';
409   local $SIG{QUIT} = 'IGNORE';
410   local $SIG{TERM} = 'IGNORE';
411   local $SIG{TSTP} = 'IGNORE';
412   local $SIG{PIPE} = 'IGNORE';
413
414   my $oldAutoCommit = $FS::UID::AutoCommit;
415   local $FS::UID::AutoCommit = 0;
416   my $dbh = dbh;
417
418   foreach my $cust_pkg_discount ($self->cust_pkg_discount) {
419     my $error = $cust_pkg_discount->delete;
420     if ( $error ) {
421       $dbh->rollback if $oldAutoCommit;
422       return $error;
423     }
424   }
425   #cust_bill_pkg_discount?
426
427   foreach my $cust_pkg_detail ($self->cust_pkg_detail) {
428     my $error = $cust_pkg_detail->delete;
429     if ( $error ) {
430       $dbh->rollback if $oldAutoCommit;
431       return $error;
432     }
433   }
434
435   foreach my $cust_pkg_reason (
436     qsearchs( {
437                 'table' => 'cust_pkg_reason',
438                 'hashref' => { 'pkgnum' => $self->pkgnum },
439               }
440             )
441   ) {
442     my $error = $cust_pkg_reason->delete;
443     if ( $error ) {
444       $dbh->rollback if $oldAutoCommit;
445       return $error;
446     }
447   }
448
449   #pkg_referral?
450
451   my $error = $self->SUPER::delete(@_);
452   if ( $error ) {
453     $dbh->rollback if $oldAutoCommit;
454     return $error;
455   }
456
457   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
458
459   '';
460
461 }
462
463 =item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
464
465 Replaces the OLD_RECORD with this one in the database.  If there is an error,
466 returns the error, otherwise returns false.
467
468 Currently, custnum, setup, bill, adjourn, susp, expire, and cancel may be changed.
469
470 Changing pkgpart may have disasterous effects.  See the order subroutine.
471
472 setup and bill are normally updated by calling the bill method of a customer
473 object (see L<FS::cust_main>).
474
475 suspend is normally updated by the suspend and unsuspend methods.
476
477 cancel is normally updated by the cancel method (and also the order subroutine
478 in some cases).
479
480 Available options are:
481
482 =over 4
483
484 =item reason
485
486 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.
487
488 =item reason_otaker
489
490 the access_user (see L<FS::access_user>) providing the reason
491
492 =item options
493
494 hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate
495
496 =back
497
498 =cut
499
500 sub replace {
501   my $new = shift;
502
503   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
504               ? shift
505               : $new->replace_old;
506
507   my $options = 
508     ( ref($_[0]) eq 'HASH' )
509       ? shift
510       : { @_ };
511
512   #return "Can't (yet?) change pkgpart!" if $old->pkgpart != $new->pkgpart;
513   #return "Can't change otaker!" if $old->otaker ne $new->otaker;
514
515   #allow this *sigh*
516   #return "Can't change setup once it exists!"
517   #  if $old->getfield('setup') &&
518   #     $old->getfield('setup') != $new->getfield('setup');
519
520   #some logic for bill, susp, cancel?
521
522   local($disable_agentcheck) = 1 if $old->pkgpart == $new->pkgpart;
523
524   local $SIG{HUP} = 'IGNORE';
525   local $SIG{INT} = 'IGNORE';
526   local $SIG{QUIT} = 'IGNORE';
527   local $SIG{TERM} = 'IGNORE';
528   local $SIG{TSTP} = 'IGNORE';
529   local $SIG{PIPE} = 'IGNORE';
530
531   my $oldAutoCommit = $FS::UID::AutoCommit;
532   local $FS::UID::AutoCommit = 0;
533   my $dbh = dbh;
534
535   foreach my $method ( qw(adjourn expire) ) {  # How many reasons?
536     if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) {
537       my $error = $new->insert_reason(
538         'reason'        => $options->{'reason'},
539         'date'          => $new->$method,
540         'action'        => $method,
541         'reason_otaker' => $options->{'reason_otaker'},
542       );
543       if ( $error ) {
544         dbh->rollback if $oldAutoCommit;
545         return "Error inserting cust_pkg_reason: $error";
546       }
547     }
548   }
549
550   #save off and freeze RADIUS attributes for any associated svc_acct records
551   my @svc_acct = ();
552   if ( $old->part_pkg->is_prepaid || $new->part_pkg->is_prepaid ) {
553
554                 #also check for specific exports?
555                 # to avoid spurious modify export events
556     @svc_acct = map  { $_->svc_x }
557                 grep { $_->part_svc->svcdb eq 'svc_acct' }
558                      $old->cust_svc;
559
560     $_->snapshot foreach @svc_acct;
561
562   }
563
564   my $error =  $new->export_pkg_change($old)
565             || $new->SUPER::replace( $old,
566                                      $options->{options}
567                                        ? $options->{options}
568                                        : ()
569                                    );
570   if ( $error ) {
571     $dbh->rollback if $oldAutoCommit;
572     return $error;
573   }
574
575   #for prepaid packages,
576   #trigger export of new RADIUS Expiration attribute when cust_pkg.bill changes
577   foreach my $old_svc_acct ( @svc_acct ) {
578     my $new_svc_acct = new FS::svc_acct { $old_svc_acct->hash };
579     my $s_error =
580       $new_svc_acct->replace( $old_svc_acct,
581                               'depend_jobnum' => $options->{depend_jobnum},
582                             );
583     if ( $s_error ) {
584       $dbh->rollback if $oldAutoCommit;
585       return $s_error;
586     }
587   }
588
589   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
590   '';
591
592 }
593
594 =item check
595
596 Checks all fields to make sure this is a valid billing item.  If there is an
597 error, returns the error, otherwise returns false.  Called by the insert and
598 replace methods.
599
600 =cut
601
602 sub check {
603   my $self = shift;
604
605   $self->locationnum('') if !$self->locationnum || $self->locationnum == -1;
606
607   my $error = 
608     $self->ut_numbern('pkgnum')
609     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
610     || $self->ut_numbern('pkgpart')
611     || $self->check_pkgpart
612     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
613     || $self->ut_numbern('start_date')
614     || $self->ut_numbern('setup')
615     || $self->ut_numbern('bill')
616     || $self->ut_numbern('susp')
617     || $self->ut_numbern('cancel')
618     || $self->ut_numbern('adjourn')
619     || $self->ut_numbern('resume')
620     || $self->ut_numbern('expire')
621     || $self->ut_numbern('dundate')
622     || $self->ut_enum('no_auto', [ '', 'Y' ])
623     || $self->ut_enum('waive_setup', [ '', 'Y' ])
624     || $self->ut_numbern('agent_pkgid')
625     || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
626     || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
627   ;
628   return $error if $error;
629
630   return "A package with both start date (future start) and setup date (already started) will never bill"
631     if $self->start_date && $self->setup;
632
633   return "A future unsuspend date can only be set for a package with a suspend date"
634     if $self->resume and !$self->susp and !$self->adjourn;
635
636   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
637
638   if ( $self->dbdef_table->column('manual_flag') ) {
639     $self->manual_flag('') if $self->manual_flag eq ' ';
640     $self->manual_flag =~ /^([01]?)$/
641       or return "Illegal manual_flag ". $self->manual_flag;
642     $self->manual_flag($1);
643   }
644
645   $self->SUPER::check;
646 }
647
648 =item check_pkgpart
649
650 =cut
651
652 sub check_pkgpart {
653   my $self = shift;
654
655   my $error = $self->ut_numbern('pkgpart');
656   return $error if $error;
657
658   if ( $self->reg_code ) {
659
660     unless ( grep { $self->pkgpart == $_->pkgpart }
661              map  { $_->reg_code_pkg }
662              qsearchs( 'reg_code', { 'code'     => $self->reg_code,
663                                      'agentnum' => $self->cust_main->agentnum })
664            ) {
665       return "Unknown registration code";
666     }
667
668   } elsif ( $self->promo_code ) {
669
670     my $promo_part_pkg =
671       qsearchs('part_pkg', {
672         'pkgpart'    => $self->pkgpart,
673         'promo_code' => { op=>'ILIKE', value=>$self->promo_code },
674       } );
675     return 'Unknown promotional code' unless $promo_part_pkg;
676
677   } else { 
678
679     unless ( $disable_agentcheck ) {
680       my $agent =
681         qsearchs( 'agent', { 'agentnum' => $self->cust_main->agentnum } );
682       return "agent ". $agent->agentnum. ':'. $agent->agent.
683              " can't purchase pkgpart ". $self->pkgpart
684         unless $agent->pkgpart_hashref->{ $self->pkgpart }
685             || $agent->agentnum == $self->part_pkg->agentnum;
686     }
687
688     $error = $self->ut_foreign_key('pkgpart', 'part_pkg', 'pkgpart' );
689     return $error if $error;
690
691   }
692
693   '';
694
695 }
696
697 =item cancel [ OPTION => VALUE ... ]
698
699 Cancels and removes all services (see L<FS::cust_svc> and L<FS::part_svc>)
700 in this package, then cancels the package itself (sets the cancel field to
701 now).
702
703 Available options are:
704
705 =over 4
706
707 =item quiet - can be set true to supress email cancellation notices.
708
709 =item time -  can be set to cancel the package based on a specific future or historical date.  Using time ensures that the remaining amount is calculated correctly.  Note however that this is an immediate cancel and just changes the date.  You are PROBABLY looking to expire the account instead of using this.
710
711 =item reason - 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.
712
713 =item date - can be set to a unix style timestamp to specify when to cancel (expire)
714
715 =item nobill - can be set true to skip billing if it might otherwise be done.
716
717 =item unused_credit - can be set to 1 to credit the remaining time, or 0 to 
718 not credit it.  This must be set (by change()) when changing the package 
719 to a different pkgpart or location, and probably shouldn't be in any other 
720 case.  If it's not set, the 'unused_credit_cancel' part_pkg option will 
721 be used.
722
723 =back
724
725 If there is an error, returns the error, otherwise returns false.
726
727 =cut
728
729 sub cancel {
730   my( $self, %options ) = @_;
731   my $error;
732
733   my $conf = new FS::Conf;
734
735   warn "cust_pkg::cancel called with options".
736        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
737     if $DEBUG;
738
739   local $SIG{HUP} = 'IGNORE';
740   local $SIG{INT} = 'IGNORE';
741   local $SIG{QUIT} = 'IGNORE'; 
742   local $SIG{TERM} = 'IGNORE';
743   local $SIG{TSTP} = 'IGNORE';
744   local $SIG{PIPE} = 'IGNORE';
745
746   my $oldAutoCommit = $FS::UID::AutoCommit;
747   local $FS::UID::AutoCommit = 0;
748   my $dbh = dbh;
749   
750   my $old = $self->select_for_update;
751
752   if ( $old->get('cancel') || $self->get('cancel') ) {
753     dbh->rollback if $oldAutoCommit;
754     return "";  # no error
755   }
756
757   my $date = $options{date} if $options{date}; # expire/cancel later
758   $date = '' if ($date && $date <= time);      # complain instead?
759
760   #race condition: usage could be ongoing until unprovisioned
761   #resolved by performing a change package instead (which unprovisions) and
762   #later cancelling
763   if ( !$options{nobill} && !$date && $conf->exists('bill_usage_on_cancel') ) {
764       my $copy = $self->new({$self->hash});
765       my $error =
766         $copy->cust_main->bill( pkg_list => [ $copy ], cancel => 1 );
767       warn "Error billing during cancel, custnum ".
768         #$self->cust_main->custnum. ": $error"
769         ": $error"
770         if $error;
771   }
772
773   my $cancel_time = $options{'time'} || time;
774
775   if ( $options{'reason'} ) {
776     $error = $self->insert_reason( 'reason' => $options{'reason'},
777                                    'action' => $date ? 'expire' : 'cancel',
778                                    'date'   => $date ? $date : $cancel_time,
779                                    'reason_otaker' => $options{'reason_otaker'},
780                                  );
781     if ( $error ) {
782       dbh->rollback if $oldAutoCommit;
783       return "Error inserting cust_pkg_reason: $error";
784     }
785   }
786
787   my %svc_cancel_opt = ();
788   $svc_cancel_opt{'date'} = $date if $date;
789   foreach my $cust_svc (
790     #schwartz
791     map  { $_->[0] }
792     sort { $a->[1] <=> $b->[1] }
793     map  { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; }
794     qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
795   ) {
796     my $part_svc = $cust_svc->part_svc;
797     next if ( defined($part_svc) and $part_svc->preserve );
798     my $error = $cust_svc->cancel( %svc_cancel_opt );
799
800     if ( $error ) {
801       $dbh->rollback if $oldAutoCommit;
802       return 'Error '. ($svc_cancel_opt{'date'} ? 'expiring' : 'canceling' ).
803              " cust_svc: $error";
804     }
805   }
806
807   unless ($date) {
808
809     # Add a credit for remaining service
810     my $last_bill = $self->getfield('last_bill') || 0;
811     my $next_bill = $self->getfield('bill') || 0;
812     my $do_credit;
813     if ( exists($options{'unused_credit'}) ) {
814       $do_credit = $options{'unused_credit'};
815     }
816     else {
817       $do_credit = $self->part_pkg->option('unused_credit_cancel', 1);
818     }
819     if ( $do_credit
820           and $last_bill > 0 # the package has been billed
821           and $next_bill > 0 # the package has a next bill date
822           and $next_bill >= $cancel_time # which is in the future
823     ) {
824       my $remaining_value = $self->calc_remain('time' => $cancel_time);
825       if ( $remaining_value > 0 ) {
826         my $error = $self->cust_main->credit(
827           $remaining_value,
828           'Credit for unused time on '. $self->part_pkg->pkg,
829           'reason_type' => $conf->config('cancel_credit_type'),
830         );
831         if ($error) {
832           $dbh->rollback if $oldAutoCommit;
833           return "Error crediting customer \$$remaining_value for unused time".
834                  " on ". $self->part_pkg->pkg. ": $error";
835         }
836       } #if $remaining_value
837     } #if $do_credit
838
839   } #unless $date
840
841   my %hash = $self->hash;
842   $date ? ($hash{'expire'} = $date) : ($hash{'cancel'} = $cancel_time);
843   my $new = new FS::cust_pkg ( \%hash );
844   $error = $new->replace( $self, options => { $self->options } );
845   if ( $error ) {
846     $dbh->rollback if $oldAutoCommit;
847     return $error;
848   }
849
850   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
851   return '' if $date; #no errors
852
853   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } $self->cust_main->invoicing_list;
854   if ( !$options{'quiet'} && 
855         $conf->exists('emailcancel', $self->cust_main->agentnum) && 
856         @invoicing_list ) {
857     my $msgnum = $conf->config('cancel_msgnum', $self->cust_main->agentnum);
858     my $error = '';
859     if ( $msgnum ) {
860       my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
861       $error = $msg_template->send( 'cust_main' => $self->cust_main,
862                                     'object'    => $self );
863     }
864     else {
865       $error = send_email(
866         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
867         'to'      => \@invoicing_list,
868         'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ),
869         'body'    => [ map "$_\n", $conf->config('cancelmessage') ],
870       );
871     }
872     #should this do something on errors?
873   }
874
875   ''; #no errors
876
877 }
878
879 =item cancel_if_expired [ NOW_TIMESTAMP ]
880
881 Cancels this package if its expire date has been reached.
882
883 =cut
884
885 sub cancel_if_expired {
886   my $self = shift;
887   my $time = shift || time;
888   return '' unless $self->expire && $self->expire <= $time;
889   my $error = $self->cancel;
890   if ( $error ) {
891     return "Error cancelling expired pkg ". $self->pkgnum. " for custnum ".
892            $self->custnum. ": $error";
893   }
894   '';
895 }
896
897 =item uncancel
898
899 "Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
900 locationnum, (other fields?).  Attempts to re-provision cancelled services
901 using history information (errors at this stage are not fatal).
902
903 cust_pkg: pass a scalar reference, will be filled in with the new cust_pkg object
904
905 svc_fatal: service provisioning errors are fatal
906
907 svc_errors: pass an array reference, will be filled in with any provisioning errors
908
909 =cut
910
911 sub uncancel {
912   my( $self, %options ) = @_;
913
914   #in case you try do do $uncancel-date = $cust_pkg->uncacel 
915   return '' unless $self->get('cancel');
916
917   ##
918   # Transaction-alize
919   ##
920
921   local $SIG{HUP} = 'IGNORE';
922   local $SIG{INT} = 'IGNORE'; 
923   local $SIG{QUIT} = 'IGNORE';
924   local $SIG{TERM} = 'IGNORE';
925   local $SIG{TSTP} = 'IGNORE'; 
926   local $SIG{PIPE} = 'IGNORE'; 
927
928   my $oldAutoCommit = $FS::UID::AutoCommit;
929   local $FS::UID::AutoCommit = 0;
930   my $dbh = dbh;
931
932   ##
933   # insert the new package
934   ##
935
936   my $cust_pkg = new FS::cust_pkg {
937     last_bill       => ( $options{'last_bill'} || $self->get('last_bill') ),
938     bill            => ( $options{'bill'}      || $self->get('bill')      ),
939     uncancel        => time,
940     uncancel_pkgnum => $self->pkgnum,
941     map { $_ => $self->get($_) } qw(
942       custnum pkgpart locationnum
943       setup
944       susp adjourn resume expire start_date contract_end dundate
945       change_date change_pkgpart change_locationnum
946       manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
947     ),
948   };
949
950   my $error = $cust_pkg->insert(
951     'change' => 1, #supresses any referral credit to a referring customer
952   );
953   if ($error) {
954     $dbh->rollback if $oldAutoCommit;
955     return $error;
956   }
957
958   ##
959   # insert services
960   ##
961
962   #find historical services within this timeframe before the package cancel
963   # (incompatible with "time" option to cust_pkg->cancel?)
964   my $fuzz = 2 * 60; #2 minutes?  too much?   (might catch separate unprovision)
965                      #            too little? (unprovisioing export delay?)
966   my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
967   my @h_cust_svc = $self->h_cust_svc( $end, $start );
968
969   my @svc_errors;
970   foreach my $h_cust_svc (@h_cust_svc) {
971     my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
972     #next unless $h_svc_x; #should this happen?
973     (my $table = $h_svc_x->table) =~ s/^h_//;
974     require "FS/$table.pm";
975     my $class = "FS::$table";
976     my $svc_x = $class->new( {
977       'pkgnum'  => $cust_pkg->pkgnum,
978       'svcpart' => $h_cust_svc->svcpart,
979       map { $_ => $h_svc_x->get($_) } fields($table)
980     } );
981
982     # radius_usergroup
983     if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
984       $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
985     }
986
987     my $svc_error = $svc_x->insert;
988     if ( $svc_error ) {
989       if ( $options{svc_fatal} ) {
990         $dbh->rollback if $oldAutoCommit;
991         return $svc_error;
992       } else {
993         # if we've failed to insert the svc_x object, svc_Common->insert 
994         # will have removed the cust_svc already.  if not, then both records
995         # were inserted but we failed for some other reason (export, most 
996         # likely).  in that case, report the error and delete the records.
997         push @svc_errors, $svc_error;
998         my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
999         if ( $cust_svc ) {
1000           # except if export_insert failed, export_delete probably won't be
1001           # much better
1002           local $FS::svc_Common::noexport_hack = 1;
1003           my $cleanup_error = $svc_x->delete; # also deletes cust_svc
1004           if ( $cleanup_error ) { # and if THAT fails, then run away
1005             $dbh->rollback if $oldAutoCommit;
1006             return $cleanup_error;
1007           }
1008         }
1009       } # svc_fatal
1010     } # svc_error
1011   } #foreach $h_cust_svc
1012
1013   #these are pretty rare, but should handle them
1014   # - dsl_device (mac addresses)
1015   # - phone_device (mac addresses)
1016   # - dsl_note (ikano notes)
1017   # - domain_record (i.e. restore DNS information w/domains)
1018   # - inventory_item(?) (inventory w/un-cancelling service?)
1019   # - nas (svc_broaband nas stuff)
1020   #this stuff is unused in the wild afaik
1021   # - mailinglistmember
1022   # - router.svcnum?
1023   # - svc_domain.parent_svcnum?
1024   # - acct_snarf (ancient mail fetching config)
1025   # - cgp_rule (communigate)
1026   # - cust_svc_option (used by our Tron stuff)
1027   # - acct_rt_transaction (used by our time worked stuff)
1028
1029   ##
1030   # also move over any services that didn't unprovision at cancellation
1031   ## 
1032
1033   foreach my $cust_svc ( qsearch('cust_svc', { pkgnum => $self->pkgnum } ) ) {
1034     $cust_svc->pkgnum( $cust_pkg->pkgnum );
1035     my $error = $cust_svc->replace;
1036     if ( $error ) {
1037       $dbh->rollback if $oldAutoCommit;
1038       return $error;
1039     }
1040   }
1041
1042   ##
1043   # Finish
1044   ##
1045
1046   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1047
1048   ${ $options{cust_pkg} }   = $cust_pkg   if ref($options{cust_pkg});
1049   @{ $options{svc_errors} } = @svc_errors if ref($options{svc_errors});
1050
1051   '';
1052 }
1053
1054 =item unexpire
1055
1056 Cancels any pending expiration (sets the expire field to null).
1057
1058 If there is an error, returns the error, otherwise returns false.
1059
1060 =cut
1061
1062 sub unexpire {
1063   my( $self, %options ) = @_;
1064   my $error;
1065
1066   local $SIG{HUP} = 'IGNORE';
1067   local $SIG{INT} = 'IGNORE';
1068   local $SIG{QUIT} = 'IGNORE';
1069   local $SIG{TERM} = 'IGNORE';
1070   local $SIG{TSTP} = 'IGNORE';
1071   local $SIG{PIPE} = 'IGNORE';
1072
1073   my $oldAutoCommit = $FS::UID::AutoCommit;
1074   local $FS::UID::AutoCommit = 0;
1075   my $dbh = dbh;
1076
1077   my $old = $self->select_for_update;
1078
1079   my $pkgnum = $old->pkgnum;
1080   if ( $old->get('cancel') || $self->get('cancel') ) {
1081     dbh->rollback if $oldAutoCommit;
1082     return "Can't unexpire cancelled package $pkgnum";
1083     # or at least it's pointless
1084   }
1085
1086   unless ( $old->get('expire') && $self->get('expire') ) {
1087     dbh->rollback if $oldAutoCommit;
1088     return "";  # no error
1089   }
1090
1091   my %hash = $self->hash;
1092   $hash{'expire'} = '';
1093   my $new = new FS::cust_pkg ( \%hash );
1094   $error = $new->replace( $self, options => { $self->options } );
1095   if ( $error ) {
1096     $dbh->rollback if $oldAutoCommit;
1097     return $error;
1098   }
1099
1100   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1101
1102   ''; #no errors
1103
1104 }
1105
1106 =item suspend [ OPTION => VALUE ... ]
1107
1108 Suspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1109 package, then suspends the package itself (sets the susp field to now).
1110
1111 Available options are:
1112
1113 =over 4
1114
1115 =item reason - can be set to a cancellation reason (see L<FS:reason>), 
1116 either a reasonnum of an existing reason, or passing a hashref will create 
1117 a new reason.  The hashref should have the following keys: 
1118 - typenum - Reason type (see L<FS::reason_type>
1119 - reason - Text of the new reason.
1120
1121 =item date - can be set to a unix style timestamp to specify when to 
1122 suspend (adjourn)
1123
1124 =item time - can be set to override the current time, for calculation 
1125 of final invoices or unused-time credits
1126
1127 =item resume_date - can be set to a time when the package should be 
1128 unsuspended.  This may be more convenient than calling C<unsuspend()>
1129 separately.
1130
1131 =back
1132
1133 If there is an error, returns the error, otherwise returns false.
1134
1135 =cut
1136
1137 sub suspend {
1138   my( $self, %options ) = @_;
1139   my $error;
1140
1141   local $SIG{HUP} = 'IGNORE';
1142   local $SIG{INT} = 'IGNORE';
1143   local $SIG{QUIT} = 'IGNORE'; 
1144   local $SIG{TERM} = 'IGNORE';
1145   local $SIG{TSTP} = 'IGNORE';
1146   local $SIG{PIPE} = 'IGNORE';
1147
1148   my $oldAutoCommit = $FS::UID::AutoCommit;
1149   local $FS::UID::AutoCommit = 0;
1150   my $dbh = dbh;
1151
1152   my $old = $self->select_for_update;
1153
1154   my $pkgnum = $old->pkgnum;
1155   if ( $old->get('cancel') || $self->get('cancel') ) {
1156     dbh->rollback if $oldAutoCommit;
1157     return "Can't suspend cancelled package $pkgnum";
1158   }
1159
1160   if ( $old->get('susp') || $self->get('susp') ) {
1161     dbh->rollback if $oldAutoCommit;
1162     return "";  # no error                     # complain on adjourn?
1163   }
1164
1165   my $suspend_time = $options{'time'} || time;
1166
1167   my $date = $options{date} if $options{date}; # adjourn/suspend later
1168   $date = '' if ($date && $date <= $suspend_time); # complain instead?
1169
1170   if ( $date && $old->get('expire') && $old->get('expire') < $date ) {
1171     dbh->rollback if $oldAutoCommit;
1172     return "Package $pkgnum expires before it would be suspended.";
1173   }
1174
1175   if ( $options{'reason'} ) {
1176     $error = $self->insert_reason( 'reason' => $options{'reason'},
1177                                    'action' => $date ? 'adjourn' : 'suspend',
1178                                    'date'   => $date ? $date : $suspend_time,
1179                                    'reason_otaker' => $options{'reason_otaker'},
1180                                  );
1181     if ( $error ) {
1182       dbh->rollback if $oldAutoCommit;
1183       return "Error inserting cust_pkg_reason: $error";
1184     }
1185   }
1186
1187   my %hash = $self->hash;
1188   if ( $date ) {
1189     $hash{'adjourn'} = $date;
1190   } else {
1191     $hash{'susp'} = $suspend_time;
1192   }
1193
1194   my $resume_date = $options{'resume_date'} || 0;
1195   if ( $resume_date > ($date || $suspend_time) ) {
1196     $hash{'resume'} = $resume_date;
1197   }
1198
1199   $options{options} ||= {};
1200
1201   my $new = new FS::cust_pkg ( \%hash );
1202   $error = $new->replace( $self, options => { $self->options,
1203                                               %{ $options{options} },
1204                                             }
1205                         );
1206   if ( $error ) {
1207     $dbh->rollback if $oldAutoCommit;
1208     return $error;
1209   }
1210
1211   unless ( $date ) {
1212
1213     my @labels = ();
1214
1215     foreach my $cust_svc (
1216       qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } )
1217     ) {
1218       my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1219
1220       $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1221         $dbh->rollback if $oldAutoCommit;
1222         return "Illegal svcdb value in part_svc!";
1223       };
1224       my $svcdb = $1;
1225       require "FS/$svcdb.pm";
1226
1227       my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1228       if ($svc) {
1229         $error = $svc->suspend;
1230         if ( $error ) {
1231           $dbh->rollback if $oldAutoCommit;
1232           return $error;
1233         }
1234         my( $label, $value ) = $cust_svc->label;
1235         push @labels, "$label: $value";
1236       }
1237     }
1238
1239     my $conf = new FS::Conf;
1240     if ( $conf->config('suspend_email_admin') ) {
1241  
1242       my $error = send_email(
1243         'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1244                                    #invoice_from ??? well as good as any
1245         'to'      => $conf->config('suspend_email_admin'),
1246         'subject' => 'FREESIDE NOTIFICATION: Customer package suspended',
1247         'body'    => [
1248           "This is an automatic message from your Freeside installation\n",
1249           "informing you that the following customer package has been suspended:\n",
1250           "\n",
1251           'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1252           'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1253           ( map { "Service : $_\n" } @labels ),
1254         ],
1255       );
1256
1257       if ( $error ) {
1258         warn "WARNING: can't send suspension admin email (suspending anyway): ".
1259              "$error\n";
1260       }
1261
1262     }
1263
1264   }
1265
1266   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1267
1268   ''; #no errors
1269 }
1270
1271 =item unsuspend [ OPTION => VALUE ... ]
1272
1273 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
1274 package, then unsuspends the package itself (clears the susp field and the
1275 adjourn field if it is in the past).  If the suspend reason includes an 
1276 unsuspension package, that package will be ordered.
1277
1278 Available options are:
1279
1280 =over 4
1281
1282 =item date
1283
1284 Can be set to a date to unsuspend the package in the future (the 'resume' 
1285 field).
1286
1287 =item adjust_next_bill
1288
1289 Can be set true to adjust the next bill date forward by
1290 the amount of time the account was inactive.  This was set true by default
1291 since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be
1292 explicitly requested.  Price plans for which this makes sense (anniversary-date
1293 based than prorate or subscription) could have an option to enable this
1294 behaviour?
1295
1296 =back
1297
1298 If there is an error, returns the error, otherwise returns false.
1299
1300 =cut
1301
1302 sub unsuspend {
1303   my( $self, %opt ) = @_;
1304   my $error;
1305
1306   local $SIG{HUP} = 'IGNORE';
1307   local $SIG{INT} = 'IGNORE';
1308   local $SIG{QUIT} = 'IGNORE'; 
1309   local $SIG{TERM} = 'IGNORE';
1310   local $SIG{TSTP} = 'IGNORE';
1311   local $SIG{PIPE} = 'IGNORE';
1312
1313   my $oldAutoCommit = $FS::UID::AutoCommit;
1314   local $FS::UID::AutoCommit = 0;
1315   my $dbh = dbh;
1316
1317   my $old = $self->select_for_update;
1318
1319   my $pkgnum = $old->pkgnum;
1320   if ( $old->get('cancel') || $self->get('cancel') ) {
1321     $dbh->rollback if $oldAutoCommit;
1322     return "Can't unsuspend cancelled package $pkgnum";
1323   }
1324
1325   unless ( $old->get('susp') && $self->get('susp') ) {
1326     $dbh->rollback if $oldAutoCommit;
1327     return "";  # no error                     # complain instead?
1328   }
1329
1330   my $date = $opt{'date'};
1331   if ( $date and $date > time ) { # return an error if $date <= time?
1332
1333     if ( $old->get('expire') && $old->get('expire') < $date ) {
1334       $dbh->rollback if $oldAutoCommit;
1335       return "Package $pkgnum expires before it would be unsuspended.";
1336     }
1337
1338     my $new = new FS::cust_pkg { $self->hash };
1339     $new->set('resume', $date);
1340     $error = $new->replace($self, options => $self->options);
1341
1342     if ( $error ) {
1343       $dbh->rollback if $oldAutoCommit;
1344       return $error;
1345     }
1346     else {
1347       $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1348       return '';
1349     }
1350   
1351   } #if $date 
1352
1353   my @labels = ();
1354
1355   foreach my $cust_svc (
1356     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
1357   ) {
1358     my $part_svc = qsearchs( 'part_svc', { 'svcpart' => $cust_svc->svcpart } );
1359
1360     $part_svc->svcdb =~ /^([\w\-]+)$/ or do {
1361       $dbh->rollback if $oldAutoCommit;
1362       return "Illegal svcdb value in part_svc!";
1363     };
1364     my $svcdb = $1;
1365     require "FS/$svcdb.pm";
1366
1367     my $svc = qsearchs( $svcdb, { 'svcnum' => $cust_svc->svcnum } );
1368     if ($svc) {
1369       $error = $svc->unsuspend;
1370       if ( $error ) {
1371         $dbh->rollback if $oldAutoCommit;
1372         return $error;
1373       }
1374       my( $label, $value ) = $cust_svc->label;
1375       push @labels, "$label: $value";
1376     }
1377
1378   }
1379
1380   my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
1381   my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
1382
1383   my %hash = $self->hash;
1384   my $inactive = time - $hash{'susp'};
1385
1386   my $conf = new FS::Conf;
1387
1388   if ( $inactive > 0 && 
1389        ( $hash{'bill'} || $hash{'setup'} ) &&
1390        ( $opt{'adjust_next_bill'} ||
1391          $conf->exists('unsuspend-always_adjust_next_bill_date') ||
1392          $self->part_pkg->option('unsuspend_adjust_bill', 1) )
1393      ) {
1394
1395     $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
1396   
1397   }
1398
1399   $hash{'susp'} = '';
1400   $hash{'adjourn'} = '' if $hash{'adjourn'} and $hash{'adjourn'} < time;
1401   $hash{'resume'} = '' if !$hash{'adjourn'};
1402   my $new = new FS::cust_pkg ( \%hash );
1403   $error = $new->replace( $self, options => { $self->options } );
1404   if ( $error ) {
1405     $dbh->rollback if $oldAutoCommit;
1406     return $error;
1407   }
1408
1409   my $unsusp_pkg;
1410
1411   if ( $reason && $reason->unsuspend_pkgpart ) {
1412     my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
1413       or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
1414                   " not found.";
1415     my $start_date = $self->cust_main->next_bill_date 
1416       if $reason->unsuspend_hold;
1417
1418     if ( $part_pkg ) {
1419       $unsusp_pkg = FS::cust_pkg->new({
1420           'custnum'     => $self->custnum,
1421           'pkgpart'     => $reason->unsuspend_pkgpart,
1422           'start_date'  => $start_date,
1423           'locationnum' => $self->locationnum,
1424           # discount? probably not...
1425       });
1426       
1427       $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
1428     }
1429
1430     if ( $error ) {
1431       $dbh->rollback if $oldAutoCommit;
1432       return $error;
1433     }
1434   }
1435
1436   if ( $conf->config('unsuspend_email_admin') ) {
1437  
1438     my $error = send_email(
1439       'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
1440                                  #invoice_from ??? well as good as any
1441       'to'      => $conf->config('unsuspend_email_admin'),
1442       'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended',       'body'    => [
1443         "This is an automatic message from your Freeside installation\n",
1444         "informing you that the following customer package has been unsuspended:\n",
1445         "\n",
1446         'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
1447         'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
1448         ( map { "Service : $_\n" } @labels ),
1449         ($unsusp_pkg ?
1450           "An unsuspension fee was charged: ".
1451             $unsusp_pkg->part_pkg->pkg_comment."\n"
1452           : ''
1453         ),
1454       ],
1455     );
1456
1457     if ( $error ) {
1458       warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
1459            "$error\n";
1460     }
1461
1462   }
1463
1464   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1465
1466   ''; #no errors
1467 }
1468
1469 =item unadjourn
1470
1471 Cancels any pending suspension (sets the adjourn field to null).
1472
1473 If there is an error, returns the error, otherwise returns false.
1474
1475 =cut
1476
1477 sub unadjourn {
1478   my( $self, %options ) = @_;
1479   my $error;
1480
1481   local $SIG{HUP} = 'IGNORE';
1482   local $SIG{INT} = 'IGNORE';
1483   local $SIG{QUIT} = 'IGNORE'; 
1484   local $SIG{TERM} = 'IGNORE';
1485   local $SIG{TSTP} = 'IGNORE';
1486   local $SIG{PIPE} = 'IGNORE';
1487
1488   my $oldAutoCommit = $FS::UID::AutoCommit;
1489   local $FS::UID::AutoCommit = 0;
1490   my $dbh = dbh;
1491
1492   my $old = $self->select_for_update;
1493
1494   my $pkgnum = $old->pkgnum;
1495   if ( $old->get('cancel') || $self->get('cancel') ) {
1496     dbh->rollback if $oldAutoCommit;
1497     return "Can't unadjourn cancelled package $pkgnum";
1498     # or at least it's pointless
1499   }
1500
1501   if ( $old->get('susp') || $self->get('susp') ) {
1502     dbh->rollback if $oldAutoCommit;
1503     return "Can't unadjourn suspended package $pkgnum";
1504     # perhaps this is arbitrary
1505   }
1506
1507   unless ( $old->get('adjourn') && $self->get('adjourn') ) {
1508     dbh->rollback if $oldAutoCommit;
1509     return "";  # no error
1510   }
1511
1512   my %hash = $self->hash;
1513   $hash{'adjourn'} = '';
1514   $hash{'resume'}  = '';
1515   my $new = new FS::cust_pkg ( \%hash );
1516   $error = $new->replace( $self, options => { $self->options } );
1517   if ( $error ) {
1518     $dbh->rollback if $oldAutoCommit;
1519     return $error;
1520   }
1521
1522   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1523
1524   ''; #no errors
1525
1526 }
1527
1528
1529 =item change HASHREF | OPTION => VALUE ... 
1530
1531 Changes this package: cancels it and creates a new one, with a different
1532 pkgpart or locationnum or both.  All services are transferred to the new
1533 package (no change will be made if this is not possible).
1534
1535 Options may be passed as a list of key/value pairs or as a hash reference.
1536 Options are:
1537
1538 =over 4
1539
1540 =item locationnum
1541
1542 New locationnum, to change the location for this package.
1543
1544 =item cust_location
1545
1546 New FS::cust_location object, to create a new location and assign it
1547 to this package.
1548
1549 =item pkgpart
1550
1551 New pkgpart (see L<FS::part_pkg>).
1552
1553 =item refnum
1554
1555 New refnum (see L<FS::part_referral>).
1556
1557 =item keep_dates
1558
1559 Set to true to transfer billing dates (start_date, setup, last_bill, bill, 
1560 susp, adjourn, cancel, expire, and contract_end) to the new package.
1561
1562 =back
1563
1564 At least one of locationnum, cust_location, pkgpart, refnum must be specified 
1565 (otherwise, what's the point?)
1566
1567 Returns either the new FS::cust_pkg object or a scalar error.
1568
1569 For example:
1570
1571   my $err_or_new_cust_pkg = $old_cust_pkg->change
1572
1573 =cut
1574
1575 #some false laziness w/order
1576 sub change {
1577   my $self = shift;
1578   my $opt = ref($_[0]) ? shift : { @_ };
1579
1580 #  my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
1581 #    
1582
1583   my $conf = new FS::Conf;
1584
1585   # Transactionize this whole mess
1586   local $SIG{HUP} = 'IGNORE';
1587   local $SIG{INT} = 'IGNORE'; 
1588   local $SIG{QUIT} = 'IGNORE';
1589   local $SIG{TERM} = 'IGNORE';
1590   local $SIG{TSTP} = 'IGNORE'; 
1591   local $SIG{PIPE} = 'IGNORE'; 
1592
1593   my $oldAutoCommit = $FS::UID::AutoCommit;
1594   local $FS::UID::AutoCommit = 0;
1595   my $dbh = dbh;
1596
1597   my $error;
1598
1599   my %hash = (); 
1600
1601   my $time = time;
1602
1603   #$hash{$_} = $self->$_() foreach qw( last_bill bill );
1604     
1605   #$hash{$_} = $self->$_() foreach qw( setup );
1606
1607   $hash{'setup'} = $time if $self->setup;
1608
1609   $hash{'change_date'} = $time;
1610   $hash{"change_$_"}  = $self->$_()
1611     foreach qw( pkgnum pkgpart locationnum );
1612
1613   if ( $opt->{'cust_location'} &&
1614        ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) {
1615     $error = $opt->{'cust_location'}->insert;
1616     if ( $error ) {
1617       $dbh->rollback if $oldAutoCommit;
1618       return "inserting cust_location (transaction rolled back): $error";
1619     }
1620     $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
1621   }
1622
1623   my $unused_credit = 0;
1624   if ( $opt->{'keep_dates'} ) {
1625     foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire 
1626                           resume start_date contract_end ) ) {
1627       $hash{$date} = $self->getfield($date);
1628     }
1629   }
1630   # Special case.  If the pkgpart is changing, and the customer is
1631   # going to be credited for remaining time, don't keep setup, bill, 
1632   # or last_bill dates, and DO pass the flag to cancel() to credit 
1633   # the customer.
1634   if ( $opt->{'pkgpart'} 
1635       and $opt->{'pkgpart'} != $self->pkgpart
1636       and $self->part_pkg->option('unused_credit_change', 1) ) {
1637     $unused_credit = 1;
1638     $hash{$_} = '' foreach qw(setup bill last_bill);
1639   }
1640
1641   # allow $opt->{'locationnum'} = '' to specifically set it to null
1642   # (i.e. customer default location)
1643   $opt->{'locationnum'} = $self->locationnum if !exists($opt->{'locationnum'});
1644
1645   # Create the new package.
1646   my $cust_pkg = new FS::cust_pkg {
1647     custnum      => $self->custnum,
1648     pkgpart      => ( $opt->{'pkgpart'}     || $self->pkgpart      ),
1649     refnum       => ( $opt->{'refnum'}      || $self->refnum       ),
1650     locationnum  => ( $opt->{'locationnum'}                        ),
1651     %hash,
1652   };
1653
1654   $error = $cust_pkg->insert( 'change' => 1 );
1655   if ($error) {
1656     $dbh->rollback if $oldAutoCommit;
1657     return $error;
1658   }
1659
1660   # Transfer services and cancel old package.
1661
1662   $error = $self->transfer($cust_pkg);
1663   if ($error and $error == 0) {
1664     # $old_pkg->transfer failed.
1665     $dbh->rollback if $oldAutoCommit;
1666     return $error;
1667   }
1668
1669   if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
1670     warn "trying transfer again with change_svcpart option\n" if $DEBUG;
1671     $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 );
1672     if ($error and $error == 0) {
1673       # $old_pkg->transfer failed.
1674       $dbh->rollback if $oldAutoCommit;
1675       return $error;
1676     }
1677   }
1678
1679   if ($error > 0) {
1680     # Transfers were successful, but we still had services left on the old
1681     # package.  We can't change the package under this circumstances, so abort.
1682     $dbh->rollback if $oldAutoCommit;
1683     return "Unable to transfer all services from package ". $self->pkgnum;
1684   }
1685
1686   #reset usage if changing pkgpart
1687   # AND usage rollover is off (otherwise adds twice, now and at package bill)
1688   if ($self->pkgpart != $cust_pkg->pkgpart) {
1689     my $part_pkg = $cust_pkg->part_pkg;
1690     $error = $part_pkg->reset_usage($cust_pkg, $part_pkg->is_prepaid
1691                                                  ? ()
1692                                                  : ( 'null' => 1 )
1693                                    )
1694       if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
1695
1696     if ($error) {
1697       $dbh->rollback if $oldAutoCommit;
1698       return "Error setting usage values: $error";
1699     }
1700   }
1701
1702   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
1703   #remaining time.
1704   $error = $self->cancel( quiet=>1, unused_credit => $unused_credit );
1705   if ($error) {
1706     $dbh->rollback if $oldAutoCommit;
1707     return $error;
1708   }
1709
1710   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
1711     #$self->cust_main
1712     my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
1713     if ( $error ) {
1714       $dbh->rollback if $oldAutoCommit;
1715       return $error;
1716     }
1717   }
1718
1719   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1720
1721   $cust_pkg;
1722
1723 }
1724
1725 use Storable 'thaw';
1726 use MIME::Base64;
1727 sub process_bulk_cust_pkg {
1728   my $job = shift;
1729   my $param = thaw(decode_base64(shift));
1730   warn Dumper($param) if $DEBUG;
1731
1732   my $old_part_pkg = qsearchs('part_pkg', 
1733                               { pkgpart => $param->{'old_pkgpart'} });
1734   my $new_part_pkg = qsearchs('part_pkg',
1735                               { pkgpart => $param->{'new_pkgpart'} });
1736   die "Must select a new package type\n" unless $new_part_pkg;
1737   #my $keep_dates = $param->{'keep_dates'} || 0;
1738   my $keep_dates = 1; # there is no good reason to turn this off
1739
1740   local $SIG{HUP} = 'IGNORE';
1741   local $SIG{INT} = 'IGNORE';
1742   local $SIG{QUIT} = 'IGNORE';
1743   local $SIG{TERM} = 'IGNORE';
1744   local $SIG{TSTP} = 'IGNORE';
1745   local $SIG{PIPE} = 'IGNORE';
1746
1747   my $oldAutoCommit = $FS::UID::AutoCommit;
1748   local $FS::UID::AutoCommit = 0;
1749   my $dbh = dbh;
1750
1751   my @cust_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
1752
1753   my $i = 0;
1754   foreach my $old_cust_pkg ( @cust_pkgs ) {
1755     $i++;
1756     $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
1757     if ( $old_cust_pkg->getfield('cancel') ) {
1758       warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
1759         $old_cust_pkg->pkgnum."\n"
1760         if $DEBUG;
1761       next;
1762     }
1763     warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
1764       if $DEBUG;
1765     my $error = $old_cust_pkg->change(
1766       'pkgpart'     => $param->{'new_pkgpart'},
1767       'keep_dates'  => $keep_dates
1768     );
1769     if ( !ref($error) ) { # change returns the cust_pkg on success
1770       $dbh->rollback;
1771       die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
1772     }
1773   }
1774   $dbh->commit if $oldAutoCommit;
1775   return;
1776 }
1777
1778 =item last_bill
1779
1780 Returns the last bill date, or if there is no last bill date, the setup date.
1781 Useful for billing metered services.
1782
1783 =cut
1784
1785 sub last_bill {
1786   my $self = shift;
1787   return $self->setfield('last_bill', $_[0]) if @_;
1788   return $self->getfield('last_bill') if $self->getfield('last_bill');
1789   my $cust_bill_pkg = qsearchs('cust_bill_pkg', { 'pkgnum' => $self->pkgnum,
1790                                                   'edate'  => $self->bill,  } );
1791   $cust_bill_pkg ? $cust_bill_pkg->sdate : $self->setup || 0;
1792 }
1793
1794 =item last_cust_pkg_reason ACTION
1795
1796 Returns the most recent ACTION FS::cust_pkg_reason associated with the package.
1797 Returns false if there is no reason or the package is not currenly ACTION'd
1798 ACTION is one of adjourn, susp, cancel, or expire.
1799
1800 =cut
1801
1802 sub last_cust_pkg_reason {
1803   my ( $self, $action ) = ( shift, shift );
1804   my $date = $self->get($action);
1805   qsearchs( {
1806               'table' => 'cust_pkg_reason',
1807               'hashref' => { 'pkgnum' => $self->pkgnum,
1808                              'action' => substr(uc($action), 0, 1),
1809                              'date'   => $date,
1810                            },
1811               'order_by' => 'ORDER BY num DESC LIMIT 1',
1812            } );
1813 }
1814
1815 =item last_reason ACTION
1816
1817 Returns the most recent ACTION FS::reason associated with the package.
1818 Returns false if there is no reason or the package is not currenly ACTION'd
1819 ACTION is one of adjourn, susp, cancel, or expire.
1820
1821 =cut
1822
1823 sub last_reason {
1824   my $cust_pkg_reason = shift->last_cust_pkg_reason(@_);
1825   $cust_pkg_reason->reason
1826     if $cust_pkg_reason;
1827 }
1828
1829 =item part_pkg
1830
1831 Returns the definition for this billing item, as an FS::part_pkg object (see
1832 L<FS::part_pkg>).
1833
1834 =cut
1835
1836 sub part_pkg {
1837   my $self = shift;
1838   return $self->{'_pkgpart'} if $self->{'_pkgpart'};
1839   cluck "cust_pkg->part_pkg called" if $DEBUG > 1;
1840   qsearchs( 'part_pkg', { 'pkgpart' => $self->pkgpart } );
1841 }
1842
1843 =item old_cust_pkg
1844
1845 Returns the cancelled package this package was changed from, if any.
1846
1847 =cut
1848
1849 sub old_cust_pkg {
1850   my $self = shift;
1851   return '' unless $self->change_pkgnum;
1852   qsearchs('cust_pkg', { 'pkgnum' => $self->change_pkgnum } );
1853 }
1854
1855 =item calc_setup
1856
1857 Calls the I<calc_setup> of the FS::part_pkg object associated with this billing
1858 item.
1859
1860 =cut
1861
1862 sub calc_setup {
1863   my $self = shift;
1864   $self->part_pkg->calc_setup($self, @_);
1865 }
1866
1867 =item calc_recur
1868
1869 Calls the I<calc_recur> of the FS::part_pkg object associated with this billing
1870 item.
1871
1872 =cut
1873
1874 sub calc_recur {
1875   my $self = shift;
1876   $self->part_pkg->calc_recur($self, @_);
1877 }
1878
1879 =item base_recur
1880
1881 Calls the I<base_recur> of the FS::part_pkg object associated with this billing
1882 item.
1883
1884 =cut
1885
1886 sub base_recur {
1887   my $self = shift;
1888   $self->part_pkg->base_recur($self, @_);
1889 }
1890
1891 =item calc_remain
1892
1893 Calls the I<calc_remain> of the FS::part_pkg object associated with this
1894 billing item.
1895
1896 =cut
1897
1898 sub calc_remain {
1899   my $self = shift;
1900   $self->part_pkg->calc_remain($self, @_);
1901 }
1902
1903 =item calc_cancel
1904
1905 Calls the I<calc_cancel> of the FS::part_pkg object associated with this
1906 billing item.
1907
1908 =cut
1909
1910 sub calc_cancel {
1911   my $self = shift;
1912   $self->part_pkg->calc_cancel($self, @_);
1913 }
1914
1915 =item cust_bill_pkg
1916
1917 Returns any invoice line items for this package (see L<FS::cust_bill_pkg>).
1918
1919 =cut
1920
1921 sub cust_bill_pkg {
1922   my $self = shift;
1923   qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } );
1924 }
1925
1926 =item cust_pkg_detail [ DETAILTYPE ]
1927
1928 Returns any customer package details for this package (see
1929 L<FS::cust_pkg_detail>).
1930
1931 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1932
1933 =cut
1934
1935 sub cust_pkg_detail {
1936   my $self = shift;
1937   my %hash = ( 'pkgnum' => $self->pkgnum );
1938   $hash{detailtype} = shift if @_;
1939   qsearch({
1940     'table'    => 'cust_pkg_detail',
1941     'hashref'  => \%hash,
1942     'order_by' => 'ORDER BY weight, pkgdetailnum',
1943   });
1944 }
1945
1946 =item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ]
1947
1948 Sets customer package details for this package (see L<FS::cust_pkg_detail>).
1949
1950 DETAILTYPE can be set to "I" for invoice details or "C" for comments.
1951
1952 If there is an error, returns the error, otherwise returns false.
1953
1954 =cut
1955
1956 sub set_cust_pkg_detail {
1957   my( $self, $detailtype, @details ) = @_;
1958
1959   local $SIG{HUP} = 'IGNORE';
1960   local $SIG{INT} = 'IGNORE';
1961   local $SIG{QUIT} = 'IGNORE';
1962   local $SIG{TERM} = 'IGNORE';
1963   local $SIG{TSTP} = 'IGNORE';
1964   local $SIG{PIPE} = 'IGNORE';
1965
1966   my $oldAutoCommit = $FS::UID::AutoCommit;
1967   local $FS::UID::AutoCommit = 0;
1968   my $dbh = dbh;
1969
1970   foreach my $current ( $self->cust_pkg_detail($detailtype) ) {
1971     my $error = $current->delete;
1972     if ( $error ) {
1973       $dbh->rollback if $oldAutoCommit;
1974       return "error removing old detail: $error";
1975     }
1976   }
1977
1978   foreach my $detail ( @details ) {
1979     my $cust_pkg_detail = new FS::cust_pkg_detail {
1980       'pkgnum'     => $self->pkgnum,
1981       'detailtype' => $detailtype,
1982       'detail'     => $detail,
1983     };
1984     my $error = $cust_pkg_detail->insert;
1985     if ( $error ) {
1986       $dbh->rollback if $oldAutoCommit;
1987       return "error adding new detail: $error";
1988     }
1989
1990   }
1991
1992   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1993   '';
1994
1995 }
1996
1997 =item cust_event
1998
1999 Returns the new-style customer billing events (see L<FS::cust_event>) for this invoice.
2000
2001 =cut
2002
2003 #false laziness w/cust_bill.pm
2004 sub cust_event {
2005   my $self = shift;
2006   qsearch({
2007     'table'     => 'cust_event',
2008     'addl_from' => 'JOIN part_event USING ( eventpart )',
2009     'hashref'   => { 'tablenum' => $self->pkgnum },
2010     'extra_sql' => " AND eventtable = 'cust_pkg' ",
2011   });
2012 }
2013
2014 =item num_cust_event
2015
2016 Returns the number of new-style customer billing events (see L<FS::cust_event>) for this invoice.
2017
2018 =cut
2019
2020 #false laziness w/cust_bill.pm
2021 sub num_cust_event {
2022   my $self = shift;
2023   my $sql =
2024     "SELECT COUNT(*) FROM cust_event JOIN part_event USING ( eventpart ) ".
2025     "  WHERE tablenum = ? AND eventtable = 'cust_pkg'";
2026   my $sth = dbh->prepare($sql) or die  dbh->errstr. " preparing $sql"; 
2027   $sth->execute($self->pkgnum) or die $sth->errstr. " executing $sql";
2028   $sth->fetchrow_arrayref->[0];
2029 }
2030
2031 =item cust_svc [ SVCPART ] (old, deprecated usage)
2032
2033 =item cust_svc [ OPTION => VALUE ... ] (current usage)
2034
2035 Returns the services for this package, as FS::cust_svc objects (see
2036 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
2037 spcififed, returns only the matching services.
2038
2039 =cut
2040
2041 sub cust_svc {
2042   my $self = shift;
2043
2044   return () unless $self->num_cust_svc(@_);
2045
2046   my %opt = ();
2047   if ( @_ && $_[0] =~ /^\d+/ ) {
2048     $opt{svcpart} = shift;
2049   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2050     %opt = %{ $_[0] };
2051   } elsif ( @_ ) {
2052     %opt = @_;
2053   }
2054
2055   my %search = (
2056     'table'   => 'cust_svc',
2057     'hashref' => { 'pkgnum' => $self->pkgnum },
2058   );
2059   if ( $opt{svcpart} ) {
2060     $search{hashref}->{svcpart} = $opt{'svcpart'};
2061   }
2062   if ( $opt{'svcdb'} ) {
2063     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
2064     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
2065   }
2066
2067   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
2068
2069   #if ( $self->{'_svcnum'} ) {
2070   #  values %{ $self->{'_svcnum'}->cache };
2071   #} else {
2072     $self->_sort_cust_svc( [ qsearch(\%search) ] );
2073   #}
2074
2075 }
2076
2077 =item overlimit [ SVCPART ]
2078
2079 Returns the services for this package which have exceeded their
2080 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
2081 is specified, return only the matching services.
2082
2083 =cut
2084
2085 sub overlimit {
2086   my $self = shift;
2087   return () unless $self->num_cust_svc(@_);
2088   grep { $_->overlimit } $self->cust_svc(@_);
2089 }
2090
2091 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2092
2093 Returns historical services for this package created before END TIMESTAMP and
2094 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
2095 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
2096 I<pkg_svc.hidden> flag will be omitted.
2097
2098 =cut
2099
2100 sub h_cust_svc {
2101   my $self = shift;
2102   warn "$me _h_cust_svc called on $self\n"
2103     if $DEBUG;
2104
2105   my ($end, $start, $mode) = @_;
2106   my @cust_svc = $self->_sort_cust_svc(
2107     [ qsearch( 'h_cust_svc',
2108       { 'pkgnum' => $self->pkgnum, },  
2109       FS::h_cust_svc->sql_h_search(@_),  
2110     ) ]
2111   );
2112   if ( defined($mode) && $mode eq 'I' ) {
2113     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
2114     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
2115   } else {
2116     return @cust_svc;
2117   }
2118 }
2119
2120 sub _sort_cust_svc {
2121   my( $self, $arrayref ) = @_;
2122
2123   my $sort =
2124     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
2125
2126   map  { $_->[0] }
2127   sort $sort
2128   map {
2129         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
2130                                              'svcpart' => $_->svcpart     } );
2131         [ $_,
2132           $pkg_svc ? $pkg_svc->primary_svc : '',
2133           $pkg_svc ? $pkg_svc->quantity : 0,
2134         ];
2135       }
2136   @$arrayref;
2137
2138 }
2139
2140 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
2141
2142 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
2143
2144 Returns the number of services for this package.  Available options are svcpart
2145 and svcdb.  If either is spcififed, returns only the matching services.
2146
2147 =cut
2148
2149 sub num_cust_svc {
2150   my $self = shift;
2151
2152   return $self->{'_num_cust_svc'}
2153     if !scalar(@_)
2154        && exists($self->{'_num_cust_svc'})
2155        && $self->{'_num_cust_svc'} =~ /\d/;
2156
2157   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
2158     if $DEBUG > 2;
2159
2160   my %opt = ();
2161   if ( @_ && $_[0] =~ /^\d+/ ) {
2162     $opt{svcpart} = shift;
2163   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2164     %opt = %{ $_[0] };
2165   } elsif ( @_ ) {
2166     %opt = @_;
2167   }
2168
2169   my $select = 'SELECT COUNT(*) FROM cust_svc ';
2170   my $where = ' WHERE pkgnum = ? ';
2171   my @param = ($self->pkgnum);
2172
2173   if ( $opt{'svcpart'} ) {
2174     $where .= ' AND svcpart = ? ';
2175     push @param, $opt{'svcpart'};
2176   }
2177   if ( $opt{'svcdb'} ) {
2178     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
2179     $where .= ' AND svcdb = ? ';
2180     push @param, $opt{'svcdb'};
2181   }
2182
2183   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
2184   $sth->execute(@param) or die $sth->errstr;
2185   $sth->fetchrow_arrayref->[0];
2186 }
2187
2188 =item available_part_svc 
2189
2190 Returns a list of FS::part_svc objects representing services included in this
2191 package but not yet provisioned.  Each FS::part_svc object also has an extra
2192 field, I<num_avail>, which specifies the number of available services.
2193
2194 =cut
2195
2196 sub available_part_svc {
2197   my $self = shift;
2198   grep { $_->num_avail > 0 }
2199     map {
2200           my $part_svc = $_->part_svc;
2201           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
2202             $_->quantity - $self->num_cust_svc($_->svcpart);
2203
2204           # more evil encapsulation breakage
2205           if($part_svc->{'Hash'}{'num_avail'} > 0) {
2206             my @exports = $part_svc->part_export_did;
2207             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
2208           }
2209
2210           $part_svc;
2211         }
2212       $self->part_pkg->pkg_svc;
2213 }
2214
2215 =item part_svc [ OPTION => VALUE ... ]
2216
2217 Returns a list of FS::part_svc objects representing provisioned and available
2218 services included in this package.  Each FS::part_svc object also has the
2219 following extra fields:
2220
2221 =over 4
2222
2223 =item num_cust_svc  (count)
2224
2225 =item num_avail     (quantity - count)
2226
2227 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
2228
2229 =back
2230
2231 Accepts one option: summarize_size.  If specified and non-zero, will omit the
2232 extra cust_pkg_svc option for objects where num_cust_svc is this size or
2233 greater.
2234
2235 =cut
2236
2237 #svcnum
2238 #label -> ($cust_svc->label)[1]
2239
2240 sub part_svc {
2241   my $self = shift;
2242   my %opt = @_;
2243
2244   #XXX some sort of sort order besides numeric by svcpart...
2245   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
2246     my $pkg_svc = $_;
2247     my $part_svc = $pkg_svc->part_svc;
2248     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2249     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
2250     $part_svc->{'Hash'}{'num_avail'}    =
2251       max( 0, $pkg_svc->quantity - $num_cust_svc );
2252     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2253         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
2254       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
2255           && $num_cust_svc >= $opt{summarize_size};
2256     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
2257     $part_svc;
2258   } $self->part_pkg->pkg_svc;
2259
2260   #extras
2261   push @part_svc, map {
2262     my $part_svc = $_;
2263     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2264     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
2265     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
2266     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2267       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
2268     $part_svc;
2269   } $self->extra_part_svc;
2270
2271   @part_svc;
2272
2273 }
2274
2275 =item extra_part_svc
2276
2277 Returns a list of FS::part_svc objects corresponding to services in this
2278 package which are still provisioned but not (any longer) available in the
2279 package definition.
2280
2281 =cut
2282
2283 sub extra_part_svc {
2284   my $self = shift;
2285
2286   my $pkgnum  = $self->pkgnum;
2287   #my $pkgpart = $self->pkgpart;
2288
2289 #  qsearch( {
2290 #    'table'     => 'part_svc',
2291 #    'hashref'   => {},
2292 #    'extra_sql' =>
2293 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
2294 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
2295 #                       AND pkg_svc.pkgpart = ?
2296 #                       AND quantity > 0 
2297 #                 )
2298 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
2299 #                       LEFT JOIN cust_pkg USING ( pkgnum )
2300 #                     WHERE cust_svc.svcpart = part_svc.svcpart
2301 #                       AND pkgnum = ?
2302 #                 )",
2303 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
2304 #  } );
2305
2306 #seems to benchmark slightly faster... (or did?)
2307
2308   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
2309   my $pkgparts = join(',', @pkgparts);
2310
2311   qsearch( {
2312     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
2313     #MySQL doesn't grok DISINCT ON
2314     'select'      => 'DISTINCT part_svc.*',
2315     'table'       => 'part_svc',
2316     'addl_from'   =>
2317       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
2318                                AND pkg_svc.pkgpart IN ($pkgparts)
2319                                AND quantity > 0
2320                              )
2321        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
2322        LEFT JOIN cust_pkg USING ( pkgnum )
2323       ",
2324     'hashref'     => {},
2325     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
2326     'extra_param' => [ [$self->pkgnum=>'int'] ],
2327   } );
2328 }
2329
2330 =item status
2331
2332 Returns a short status string for this package, currently:
2333
2334 =over 4
2335
2336 =item not yet billed
2337
2338 =item one-time charge
2339
2340 =item active
2341
2342 =item suspended
2343
2344 =item cancelled
2345
2346 =back
2347
2348 =cut
2349
2350 sub status {
2351   my $self = shift;
2352
2353   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2354
2355   return 'cancelled' if $self->get('cancel');
2356   return 'suspended' if $self->susp;
2357   return 'not yet billed' unless $self->setup;
2358   return 'one-time charge' if $freq =~ /^(0|$)/;
2359   return 'active';
2360 }
2361
2362 =item ucfirst_status
2363
2364 Returns the status with the first character capitalized.
2365
2366 =cut
2367
2368 sub ucfirst_status {
2369   ucfirst(shift->status);
2370 }
2371
2372 =item statuses
2373
2374 Class method that returns the list of possible status strings for packages
2375 (see L<the status method|/status>).  For example:
2376
2377   @statuses = FS::cust_pkg->statuses();
2378
2379 =cut
2380
2381 tie my %statuscolor, 'Tie::IxHash', 
2382   'not yet billed'  => '009999', #teal? cyan?
2383   'one-time charge' => '000000',
2384   'active'          => '00CC00',
2385   'suspended'       => 'FF9900',
2386   'cancelled'       => 'FF0000',
2387 ;
2388
2389 sub statuses {
2390   my $self = shift; #could be class...
2391   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2392   #                                    # mayble split btw one-time vs. recur
2393     keys %statuscolor;
2394 }
2395
2396 =item statuscolor
2397
2398 Returns a hex triplet color string for this package's status.
2399
2400 =cut
2401
2402 sub statuscolor {
2403   my $self = shift;
2404   $statuscolor{$self->status};
2405 }
2406
2407 =item pkg_label
2408
2409 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
2410 "pkg-comment" depending on user preference).
2411
2412 =cut
2413
2414 sub pkg_label {
2415   my $self = shift;
2416   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
2417   $label = $self->pkgnum. ": $label"
2418     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
2419   $label;
2420 }
2421
2422 =item pkg_label_long
2423
2424 Returns a long label for this package, adding the primary service's label to
2425 pkg_label.
2426
2427 =cut
2428
2429 sub pkg_label_long {
2430   my $self = shift;
2431   my $label = $self->pkg_label;
2432   my $cust_svc = $self->primary_cust_svc;
2433   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
2434   $label;
2435 }
2436
2437 =item primary_cust_svc
2438
2439 Returns a primary service (as FS::cust_svc object) if one can be identified.
2440
2441 =cut
2442
2443 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
2444
2445 sub primary_cust_svc {
2446   my $self = shift;
2447
2448   my @cust_svc = $self->cust_svc;
2449
2450   return '' unless @cust_svc; #no serivces - irrelevant then
2451   
2452   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
2453
2454   # primary service as specified in the package definition
2455   # or exactly one service definition with quantity one
2456   my $svcpart = $self->part_pkg->svcpart;
2457   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
2458   return $cust_svc[0] if scalar(@cust_svc) == 1;
2459
2460   #couldn't identify one thing..
2461   return '';
2462 }
2463
2464 =item labels
2465
2466 Returns a list of lists, calling the label method for all services
2467 (see L<FS::cust_svc>) of this billing item.
2468
2469 =cut
2470
2471 sub labels {
2472   my $self = shift;
2473   map { [ $_->label ] } $self->cust_svc;
2474 }
2475
2476 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2477
2478 Like the labels method, but returns historical information on services that
2479 were active as of END_TIMESTAMP and (optionally) not cancelled before
2480 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
2481 I<pkg_svc.hidden> flag will be omitted.
2482
2483 Returns a list of lists, calling the label method for all (historical) services
2484 (see L<FS::h_cust_svc>) of this billing item.
2485
2486 =cut
2487
2488 sub h_labels {
2489   my $self = shift;
2490   warn "$me _h_labels called on $self\n"
2491     if $DEBUG;
2492   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
2493 }
2494
2495 =item labels_short
2496
2497 Like labels, except returns a simple flat list, and shortens long
2498 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2499 identical services to one line that lists the service label and the number of
2500 individual services rather than individual items.
2501
2502 =cut
2503
2504 sub labels_short {
2505   shift->_labels_short( 'labels', @_ );
2506 }
2507
2508 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
2509
2510 Like h_labels, except returns a simple flat list, and shortens long
2511 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2512 identical services to one line that lists the service label and the number of
2513 individual services rather than individual items.
2514
2515 =cut
2516
2517 sub h_labels_short {
2518   shift->_labels_short( 'h_labels', @_ );
2519 }
2520
2521 sub _labels_short {
2522   my( $self, $method ) = ( shift, shift );
2523
2524   warn "$me _labels_short called on $self with $method method\n"
2525     if $DEBUG;
2526
2527   my $conf = new FS::Conf;
2528   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
2529
2530   warn "$me _labels_short populating \%labels\n"
2531     if $DEBUG;
2532
2533   my %labels;
2534   #tie %labels, 'Tie::IxHash';
2535   push @{ $labels{$_->[0]} }, $_->[1]
2536     foreach $self->$method(@_);
2537
2538   warn "$me _labels_short populating \@labels\n"
2539     if $DEBUG;
2540
2541   my @labels;
2542   foreach my $label ( keys %labels ) {
2543     my %seen = ();
2544     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
2545     my $num = scalar(@values);
2546     warn "$me _labels_short $num items for $label\n"
2547       if $DEBUG;
2548
2549     if ( $num > $max_same_services ) {
2550       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
2551         if $DEBUG;
2552       push @labels, "$label ($num)";
2553     } else {
2554       if ( $conf->exists('cust_bill-consolidate_services') ) {
2555         warn "$me _labels_short   consolidating services\n"
2556           if $DEBUG;
2557         # push @labels, "$label: ". join(', ', @values);
2558         while ( @values ) {
2559           my $detail = "$label: ";
2560           $detail .= shift(@values). ', '
2561             while @values
2562                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
2563           $detail =~ s/, $//;
2564           push @labels, $detail;
2565         }
2566         warn "$me _labels_short   done consolidating services\n"
2567           if $DEBUG;
2568       } else {
2569         warn "$me _labels_short   adding service data\n"
2570           if $DEBUG;
2571         push @labels, map { "$label: $_" } @values;
2572       }
2573     }
2574   }
2575
2576  @labels;
2577
2578 }
2579
2580 =item cust_main
2581
2582 Returns the parent customer object (see L<FS::cust_main>).
2583
2584 =cut
2585
2586 sub cust_main {
2587   my $self = shift;
2588   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
2589 }
2590
2591 =item balance
2592
2593 Returns the balance for this specific package, when using
2594 experimental package balance.
2595
2596 =cut
2597
2598 sub balance {
2599   my $self = shift;
2600   $self->cust_main->balance_pkgnum( $self->pkgnum );
2601 }
2602
2603 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
2604
2605 =item cust_location
2606
2607 Returns the location object, if any (see L<FS::cust_location>).
2608
2609 =item cust_location_or_main
2610
2611 If this package is associated with a location, returns the locaiton (see
2612 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
2613
2614 =item location_label [ OPTION => VALUE ... ]
2615
2616 Returns the label of the location object (see L<FS::cust_location>).
2617
2618 =cut
2619
2620 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
2621
2622 =item seconds_since TIMESTAMP
2623
2624 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
2625 package have been online since TIMESTAMP, according to the session monitor.
2626
2627 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
2628 L<Time::Local> and L<Date::Parse> for conversion functions.
2629
2630 =cut
2631
2632 sub seconds_since {
2633   my($self, $since) = @_;
2634   my $seconds = 0;
2635
2636   foreach my $cust_svc (
2637     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
2638   ) {
2639     $seconds += $cust_svc->seconds_since($since);
2640   }
2641
2642   $seconds;
2643
2644 }
2645
2646 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2647
2648 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2649 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2650 (exclusive).
2651
2652 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2653 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2654 functions.
2655
2656
2657 =cut
2658
2659 sub seconds_since_sqlradacct {
2660   my($self, $start, $end) = @_;
2661
2662   my $seconds = 0;
2663
2664   foreach my $cust_svc (
2665     grep {
2666       my $part_svc = $_->part_svc;
2667       $part_svc->svcdb eq 'svc_acct'
2668         && scalar($part_svc->part_export_usage);
2669     } $self->cust_svc
2670   ) {
2671     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2672   }
2673
2674   $seconds;
2675
2676 }
2677
2678 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2679
2680 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2681 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2682 TIMESTAMP_END
2683 (exclusive).
2684
2685 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2686 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2687 functions.
2688
2689 =cut
2690
2691 sub attribute_since_sqlradacct {
2692   my($self, $start, $end, $attrib) = @_;
2693
2694   my $sum = 0;
2695
2696   foreach my $cust_svc (
2697     grep {
2698       my $part_svc = $_->part_svc;
2699       $part_svc->svcdb eq 'svc_acct'
2700         && scalar($part_svc->part_export_usage);
2701     } $self->cust_svc
2702   ) {
2703     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2704   }
2705
2706   $sum;
2707
2708 }
2709
2710 =item quantity
2711
2712 =cut
2713
2714 sub quantity {
2715   my( $self, $value ) = @_;
2716   if ( defined($value) ) {
2717     $self->setfield('quantity', $value);
2718   }
2719   $self->getfield('quantity') || 1;
2720 }
2721
2722 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2723
2724 Transfers as many services as possible from this package to another package.
2725
2726 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2727 object.  The destination package must already exist.
2728
2729 Services are moved only if the destination allows services with the correct
2730 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
2731 this option with caution!  No provision is made for export differences
2732 between the old and new service definitions.  Probably only should be used
2733 when your exports for all service definitions of a given svcdb are identical.
2734 (attempt a transfer without it first, to move all possible svcpart-matching
2735 services)
2736
2737 Any services that can't be moved remain in the original package.
2738
2739 Returns an error, if there is one; otherwise, returns the number of services 
2740 that couldn't be moved.
2741
2742 =cut
2743
2744 sub transfer {
2745   my ($self, $dest_pkgnum, %opt) = @_;
2746
2747   my $remaining = 0;
2748   my $dest;
2749   my %target;
2750
2751   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2752     $dest = $dest_pkgnum;
2753     $dest_pkgnum = $dest->pkgnum;
2754   } else {
2755     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2756   }
2757
2758   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2759
2760   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2761     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2762   }
2763
2764   foreach my $cust_svc ($dest->cust_svc) {
2765     $target{$cust_svc->svcpart}--;
2766   }
2767
2768   my %svcpart2svcparts = ();
2769   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2770     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2771     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2772       next if exists $svcpart2svcparts{$svcpart};
2773       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2774       $svcpart2svcparts{$svcpart} = [
2775         map  { $_->[0] }
2776         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
2777         map {
2778               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2779                                                    'svcpart' => $_          } );
2780               [ $_,
2781                 $pkg_svc ? $pkg_svc->primary_svc : '',
2782                 $pkg_svc ? $pkg_svc->quantity : 0,
2783               ];
2784             }
2785
2786         grep { $_ != $svcpart }
2787         map  { $_->svcpart }
2788         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2789       ];
2790       warn "alternates for svcpart $svcpart: ".
2791            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2792         if $DEBUG;
2793     }
2794   }
2795
2796   foreach my $cust_svc ($self->cust_svc) {
2797     if($target{$cust_svc->svcpart} > 0) {
2798       $target{$cust_svc->svcpart}--;
2799       my $new = new FS::cust_svc { $cust_svc->hash };
2800       $new->pkgnum($dest_pkgnum);
2801       my $error = $new->replace($cust_svc);
2802       return $error if $error;
2803     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2804       if ( $DEBUG ) {
2805         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2806         warn "alternates to consider: ".
2807              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2808       }
2809       my @alternate = grep {
2810                              warn "considering alternate svcpart $_: ".
2811                                   "$target{$_} available in new package\n"
2812                                if $DEBUG;
2813                              $target{$_} > 0;
2814                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
2815       if ( @alternate ) {
2816         warn "alternate(s) found\n" if $DEBUG;
2817         my $change_svcpart = $alternate[0];
2818         $target{$change_svcpart}--;
2819         my $new = new FS::cust_svc { $cust_svc->hash };
2820         $new->svcpart($change_svcpart);
2821         $new->pkgnum($dest_pkgnum);
2822         my $error = $new->replace($cust_svc);
2823         return $error if $error;
2824       } else {
2825         $remaining++;
2826       }
2827     } else {
2828       $remaining++
2829     }
2830   }
2831   return $remaining;
2832 }
2833
2834 =item reexport
2835
2836 This method is deprecated.  See the I<depend_jobnum> option to the insert and
2837 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2838
2839 =cut
2840
2841 sub reexport {
2842   my $self = shift;
2843
2844   local $SIG{HUP} = 'IGNORE';
2845   local $SIG{INT} = 'IGNORE';
2846   local $SIG{QUIT} = 'IGNORE';
2847   local $SIG{TERM} = 'IGNORE';
2848   local $SIG{TSTP} = 'IGNORE';
2849   local $SIG{PIPE} = 'IGNORE';
2850
2851   my $oldAutoCommit = $FS::UID::AutoCommit;
2852   local $FS::UID::AutoCommit = 0;
2853   my $dbh = dbh;
2854
2855   foreach my $cust_svc ( $self->cust_svc ) {
2856     #false laziness w/svc_Common::insert
2857     my $svc_x = $cust_svc->svc_x;
2858     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2859       my $error = $part_export->export_insert($svc_x);
2860       if ( $error ) {
2861         $dbh->rollback if $oldAutoCommit;
2862         return $error;
2863       }
2864     }
2865   }
2866
2867   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2868   '';
2869
2870 }
2871
2872 =item export_pkg_change OLD_CUST_PKG
2873
2874 Calls the "pkg_change" export action for all services attached to this package.
2875
2876 =cut
2877
2878 sub export_pkg_change {
2879   my( $self, $old )  = ( shift, shift );
2880
2881   local $SIG{HUP} = 'IGNORE';
2882   local $SIG{INT} = 'IGNORE';
2883   local $SIG{QUIT} = 'IGNORE';
2884   local $SIG{TERM} = 'IGNORE';
2885   local $SIG{TSTP} = 'IGNORE';
2886   local $SIG{PIPE} = 'IGNORE';
2887
2888   my $oldAutoCommit = $FS::UID::AutoCommit;
2889   local $FS::UID::AutoCommit = 0;
2890   my $dbh = dbh;
2891
2892   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
2893     my $error = $svc_x->export('pkg_change', $self, $old);
2894     if ( $error ) {
2895       $dbh->rollback if $oldAutoCommit;
2896       return $error;
2897     }
2898   }
2899
2900   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2901   '';
2902
2903 }
2904
2905 =item insert_reason
2906
2907 Associates this package with a (suspension or cancellation) reason (see
2908 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2909 L<FS::reason>).
2910
2911 Available options are:
2912
2913 =over 4
2914
2915 =item reason
2916
2917 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.
2918
2919 =item reason_otaker
2920
2921 the access_user (see L<FS::access_user>) providing the reason
2922
2923 =item date
2924
2925 a unix timestamp 
2926
2927 =item action
2928
2929 the action (cancel, susp, adjourn, expire) associated with the reason
2930
2931 =back
2932
2933 If there is an error, returns the error, otherwise returns false.
2934
2935 =cut
2936
2937 sub insert_reason {
2938   my ($self, %options) = @_;
2939
2940   my $otaker = $options{reason_otaker} ||
2941                $FS::CurrentUser::CurrentUser->username;
2942
2943   my $reasonnum;
2944   if ( $options{'reason'} =~ /^(\d+)$/ ) {
2945
2946     $reasonnum = $1;
2947
2948   } elsif ( ref($options{'reason'}) ) {
2949   
2950     return 'Enter a new reason (or select an existing one)'
2951       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2952
2953     my $reason = new FS::reason({
2954       'reason_type' => $options{'reason'}->{'typenum'},
2955       'reason'      => $options{'reason'}->{'reason'},
2956     });
2957     my $error = $reason->insert;
2958     return $error if $error;
2959
2960     $reasonnum = $reason->reasonnum;
2961
2962   } else {
2963     return "Unparsable reason: ". $options{'reason'};
2964   }
2965
2966   my $cust_pkg_reason =
2967     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2968                               'reasonnum' => $reasonnum, 
2969                               'otaker'    => $otaker,
2970                               'action'    => substr(uc($options{'action'}),0,1),
2971                               'date'      => $options{'date'}
2972                                                ? $options{'date'}
2973                                                : time,
2974                             });
2975
2976   $cust_pkg_reason->insert;
2977 }
2978
2979 =item insert_discount
2980
2981 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
2982 inserting a new discount on the fly (see L<FS::discount>).
2983
2984 Available options are:
2985
2986 =over 4
2987
2988 =item discountnum
2989
2990 =back
2991
2992 If there is an error, returns the error, otherwise returns false.
2993
2994 =cut
2995
2996 sub insert_discount {
2997   #my ($self, %options) = @_;
2998   my $self = shift;
2999
3000   my $cust_pkg_discount = new FS::cust_pkg_discount {
3001     'pkgnum'      => $self->pkgnum,
3002     'discountnum' => $self->discountnum,
3003     'months_used' => 0,
3004     'end_date'    => '', #XXX
3005     #for the create a new discount case
3006     '_type'       => $self->discountnum__type,
3007     'amount'      => $self->discountnum_amount,
3008     'percent'     => $self->discountnum_percent,
3009     'months'      => $self->discountnum_months,
3010     'setup'      => $self->discountnum_setup,
3011     #'disabled'    => $self->discountnum_disabled,
3012   };
3013
3014   $cust_pkg_discount->insert;
3015 }
3016
3017 =item set_usage USAGE_VALUE_HASHREF 
3018
3019 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3020 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3021 upbytes, downbytes, and totalbytes are appropriate keys.
3022
3023 All svc_accts which are part of this package have their values reset.
3024
3025 =cut
3026
3027 sub set_usage {
3028   my ($self, $valueref, %opt) = @_;
3029
3030   #only svc_acct can set_usage for now
3031   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3032     my $svc_x = $cust_svc->svc_x;
3033     $svc_x->set_usage($valueref, %opt)
3034       if $svc_x->can("set_usage");
3035   }
3036 }
3037
3038 =item recharge USAGE_VALUE_HASHREF 
3039
3040 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3041 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3042 upbytes, downbytes, and totalbytes are appropriate keys.
3043
3044 All svc_accts which are part of this package have their values incremented.
3045
3046 =cut
3047
3048 sub recharge {
3049   my ($self, $valueref) = @_;
3050
3051   #only svc_acct can set_usage for now
3052   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3053     my $svc_x = $cust_svc->svc_x;
3054     $svc_x->recharge($valueref)
3055       if $svc_x->can("recharge");
3056   }
3057 }
3058
3059 =item cust_pkg_discount
3060
3061 =cut
3062
3063 sub cust_pkg_discount {
3064   my $self = shift;
3065   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
3066 }
3067
3068 =item cust_pkg_discount_active
3069
3070 =cut
3071
3072 sub cust_pkg_discount_active {
3073   my $self = shift;
3074   grep { $_->status eq 'active' } $self->cust_pkg_discount;
3075 }
3076
3077 =back
3078
3079 =head1 CLASS METHODS
3080
3081 =over 4
3082
3083 =item recurring_sql
3084
3085 Returns an SQL expression identifying recurring packages.
3086
3087 =cut
3088
3089 sub recurring_sql { "
3090   '0' != ( select freq from part_pkg
3091              where cust_pkg.pkgpart = part_pkg.pkgpart )
3092 "; }
3093
3094 =item onetime_sql
3095
3096 Returns an SQL expression identifying one-time packages.
3097
3098 =cut
3099
3100 sub onetime_sql { "
3101   '0' = ( select freq from part_pkg
3102             where cust_pkg.pkgpart = part_pkg.pkgpart )
3103 "; }
3104
3105 =item ordered_sql
3106
3107 Returns an SQL expression identifying ordered packages (recurring packages not
3108 yet billed).
3109
3110 =cut
3111
3112 sub ordered_sql {
3113    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
3114 }
3115
3116 =item active_sql
3117
3118 Returns an SQL expression identifying active packages.
3119
3120 =cut
3121
3122 sub active_sql {
3123   $_[0]->recurring_sql. "
3124   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3125   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3126   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3127 "; }
3128
3129 =item not_yet_billed_sql
3130
3131 Returns an SQL expression identifying packages which have not yet been billed.
3132
3133 =cut
3134
3135 sub not_yet_billed_sql { "
3136       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
3137   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3138   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3139 "; }
3140
3141 =item inactive_sql
3142
3143 Returns an SQL expression identifying inactive packages (one-time packages
3144 that are otherwise unsuspended/uncancelled).
3145
3146 =cut
3147
3148 sub inactive_sql { "
3149   ". $_[0]->onetime_sql(). "
3150   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3151   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3152   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3153 "; }
3154
3155 =item susp_sql
3156 =item suspended_sql
3157
3158 Returns an SQL expression identifying suspended packages.
3159
3160 =cut
3161
3162 sub suspended_sql { susp_sql(@_); }
3163 sub susp_sql {
3164   #$_[0]->recurring_sql(). ' AND '.
3165   "
3166         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
3167     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
3168   ";
3169 }
3170
3171 =item cancel_sql
3172 =item cancelled_sql
3173
3174 Returns an SQL exprression identifying cancelled packages.
3175
3176 =cut
3177
3178 sub cancelled_sql { cancel_sql(@_); }
3179 sub cancel_sql { 
3180   #$_[0]->recurring_sql(). ' AND '.
3181   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
3182 }
3183
3184 =item status_sql
3185
3186 Returns an SQL expression to give the package status as a string.
3187
3188 =cut
3189
3190 sub status_sql {
3191 "CASE
3192   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
3193   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
3194   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
3195   WHEN ".onetime_sql()." THEN 'one-time charge'
3196   ELSE 'active'
3197 END"
3198 }
3199
3200 =item search HASHREF
3201
3202 (Class method)
3203
3204 Returns a qsearch hash expression to search for parameters specified in HASHREF.
3205 Valid parameters are
3206
3207 =over 4
3208
3209 =item agentnum
3210
3211 =item magic
3212
3213 active, inactive, suspended, cancel (or cancelled)
3214
3215 =item status
3216
3217 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
3218
3219 =item custom
3220
3221  boolean selects custom packages
3222
3223 =item classnum
3224
3225 =item pkgpart
3226
3227 pkgpart or arrayref or hashref of pkgparts
3228
3229 =item setup
3230
3231 arrayref of beginning and ending epoch date
3232
3233 =item last_bill
3234
3235 arrayref of beginning and ending epoch date
3236
3237 =item bill
3238
3239 arrayref of beginning and ending epoch date
3240
3241 =item adjourn
3242
3243 arrayref of beginning and ending epoch date
3244
3245 =item susp
3246
3247 arrayref of beginning and ending epoch date
3248
3249 =item expire
3250
3251 arrayref of beginning and ending epoch date
3252
3253 =item cancel
3254
3255 arrayref of beginning and ending epoch date
3256
3257 =item query
3258
3259 pkgnum or APKG_pkgnum
3260
3261 =item cust_fields
3262
3263 a value suited to passing to FS::UI::Web::cust_header
3264
3265 =item CurrentUser
3266
3267 specifies the user for agent virtualization
3268
3269 =item fcc_line
3270
3271 boolean; if true, returns only packages with more than 0 FCC phone lines
3272
3273 =item state, country
3274
3275 Limit to packages whose customer is located in the specified state and 
3276 country.  For FCC 477 reporting.  This will use the customer's service 
3277 address if there is one, but isn't yet smart enough to use the package 
3278 address.
3279
3280 =back
3281
3282 =cut
3283
3284 sub search {
3285   my ($class, $params) = @_;
3286   my @where = ();
3287
3288   ##
3289   # parse agent
3290   ##
3291
3292   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
3293     push @where,
3294       "cust_main.agentnum = $1";
3295   }
3296
3297   ##
3298   # parse custnum
3299   ##
3300
3301   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
3302     push @where,
3303       "cust_pkg.custnum = $1";
3304   }
3305
3306   ##
3307   # custbatch
3308   ##
3309
3310   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
3311     push @where,
3312       "cust_pkg.pkgbatch = '$1'";
3313   }
3314
3315   ##
3316   # parse status
3317   ##
3318
3319   if (    $params->{'magic'}  eq 'active'
3320        || $params->{'status'} eq 'active' ) {
3321
3322     push @where, FS::cust_pkg->active_sql();
3323
3324   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
3325             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
3326
3327     push @where, FS::cust_pkg->not_yet_billed_sql();
3328
3329   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
3330             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
3331
3332     push @where, FS::cust_pkg->inactive_sql();
3333
3334   } elsif (    $params->{'magic'}  eq 'suspended'
3335             || $params->{'status'} eq 'suspended'  ) {
3336
3337     push @where, FS::cust_pkg->suspended_sql();
3338
3339   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
3340             || $params->{'status'} =~ /^cancell?ed$/ ) {
3341
3342     push @where, FS::cust_pkg->cancelled_sql();
3343
3344   }
3345
3346   ###
3347   # parse package class
3348   ###
3349
3350   if ( exists($params->{'classnum'}) ) {
3351
3352     my @classnum = ();
3353     if ( ref($params->{'classnum'}) ) {
3354
3355       if ( ref($params->{'classnum'}) eq 'HASH' ) {
3356         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
3357       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
3358         @classnum = @{ $params->{'classnum'} };
3359       } else {
3360         die 'unhandled classnum ref '. $params->{'classnum'};
3361       }
3362
3363
3364     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
3365       @classnum = ( $1 );
3366     }
3367
3368     if ( @classnum ) {
3369
3370       my @c_where = ();
3371       my @nums = grep $_, @classnum;
3372       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
3373       my $null = scalar( grep { $_ eq '' } @classnum );
3374       push @c_where, 'part_pkg.classnum IS NULL' if $null;
3375
3376       if ( scalar(@c_where) == 1 ) {
3377         push @where, @c_where;
3378       } elsif ( @c_where ) {
3379         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
3380       }
3381
3382     }
3383     
3384
3385   }
3386
3387   ###
3388   # parse package report options
3389   ###
3390
3391   my @report_option = ();
3392   if ( exists($params->{'report_option'}) ) {
3393     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
3394       @report_option = @{ $params->{'report_option'} };
3395     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
3396       @report_option = split(',', $1);
3397     }
3398
3399   }
3400
3401   if (@report_option) {
3402     # this will result in the empty set for the dangling comma case as it should
3403     push @where, 
3404       map{ "0 < ( SELECT count(*) FROM part_pkg_option
3405                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3406                     AND optionname = 'report_option_$_'
3407                     AND optionvalue = '1' )"
3408          } @report_option;
3409   }
3410
3411   foreach my $any ( grep /^report_option_any/, keys %$params ) {
3412
3413     my @report_option_any = ();
3414     if ( ref($params->{$any}) eq 'ARRAY' ) {
3415       @report_option_any = @{ $params->{$any} };
3416     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
3417       @report_option_any = split(',', $1);
3418     }
3419
3420     if (@report_option_any) {
3421       # this will result in the empty set for the dangling comma case as it should
3422       push @where, ' ( '. join(' OR ',
3423         map{ "0 < ( SELECT count(*) FROM part_pkg_option
3424                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3425                       AND optionname = 'report_option_$_'
3426                       AND optionvalue = '1' )"
3427            } @report_option_any
3428       ). ' ) ';
3429     }
3430
3431   }
3432
3433   ###
3434   # parse custom
3435   ###
3436
3437   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
3438
3439   ###
3440   # parse fcc_line
3441   ###
3442
3443   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
3444                                                         if $params->{fcc_line};
3445
3446   ###
3447   # parse censustract
3448   ###
3449
3450   if ( exists($params->{'censustract'}) ) {
3451     $params->{'censustract'} =~ /^([.\d]*)$/;
3452     my $censustract = "cust_main.censustract = '$1'";
3453     $censustract .= ' OR cust_main.censustract is NULL' unless $1;
3454     push @where,  "( $censustract )";
3455   }
3456
3457   ###
3458   # parse censustract2
3459   ###
3460   if ( exists($params->{'censustract2'})
3461        && $params->{'censustract2'} =~ /^(\d*)$/
3462      )
3463   {
3464     if ($1) {
3465       push @where, "cust_main.censustract LIKE '$1%'";
3466     } else {
3467       push @where,
3468         "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
3469     }
3470   }
3471
3472   ###
3473   # parse country/state
3474   ###
3475
3476   for (qw(state country)) {
3477     if ( exists($params->{$_})
3478       && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
3479     {
3480       push @where, 
3481         "COALESCE(cust_location.$_, cust_main.ship_$_, cust_main.$_) = '$1'";
3482     }
3483   }
3484
3485
3486   ###
3487   # parse part_pkg
3488   ###
3489
3490   if ( ref($params->{'pkgpart'}) ) {
3491
3492     my @pkgpart = ();
3493     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
3494       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
3495     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
3496       @pkgpart = @{ $params->{'pkgpart'} };
3497     } else {
3498       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
3499     }
3500
3501     @pkgpart = grep /^(\d+)$/, @pkgpart;
3502
3503     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
3504
3505   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3506     push @where, "pkgpart = $1";
3507   } 
3508
3509   ###
3510   # parse dates
3511   ###
3512
3513   my $orderby = '';
3514
3515   #false laziness w/report_cust_pkg.html
3516   my %disable = (
3517     'all'             => {},
3518     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
3519     'active'          => { 'susp'=>1, 'cancel'=>1 },
3520     'suspended'       => { 'cancel' => 1 },
3521     'cancelled'       => {},
3522     ''                => {},
3523   );
3524
3525   if( exists($params->{'active'} ) ) {
3526     # This overrides all the other date-related fields
3527     my($beginning, $ending) = @{$params->{'active'}};
3528     push @where,
3529       "cust_pkg.setup IS NOT NULL",
3530       "cust_pkg.setup <= $ending",
3531       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
3532       "NOT (".FS::cust_pkg->onetime_sql . ")";
3533   }
3534   else {
3535     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
3536
3537       next unless exists($params->{$field});
3538
3539       my($beginning, $ending) = @{$params->{$field}};
3540
3541       next if $beginning == 0 && $ending == 4294967295;
3542
3543       push @where,
3544         "cust_pkg.$field IS NOT NULL",
3545         "cust_pkg.$field >= $beginning",
3546         "cust_pkg.$field <= $ending";
3547
3548       $orderby ||= "ORDER BY cust_pkg.$field";
3549
3550     }
3551   }
3552
3553   $orderby ||= 'ORDER BY bill';
3554
3555   ###
3556   # parse magic, legacy, etc.
3557   ###
3558
3559   if ( $params->{'magic'} &&
3560        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
3561   ) {
3562
3563     $orderby = 'ORDER BY pkgnum';
3564
3565     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3566       push @where, "pkgpart = $1";
3567     }
3568
3569   } elsif ( $params->{'query'} eq 'pkgnum' ) {
3570
3571     $orderby = 'ORDER BY pkgnum';
3572
3573   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
3574
3575     $orderby = 'ORDER BY pkgnum';
3576
3577     push @where, '0 < (
3578       SELECT count(*) FROM pkg_svc
3579        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
3580          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
3581                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
3582                                      AND cust_svc.svcpart = pkg_svc.svcpart
3583                                 )
3584     )';
3585   
3586   }
3587
3588   ##
3589   # setup queries, links, subs, etc. for the search
3590   ##
3591
3592   # here is the agent virtualization
3593   if ($params->{CurrentUser}) {
3594     my $access_user =
3595       qsearchs('access_user', { username => $params->{CurrentUser} });
3596
3597     if ($access_user) {
3598       push @where, $access_user->agentnums_sql('table'=>'cust_main');
3599     } else {
3600       push @where, "1=0";
3601     }
3602   } else {
3603     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
3604   }
3605
3606   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
3607
3608   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
3609                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
3610                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
3611                   'LEFT JOIN cust_location USING ( locationnum ) ';
3612
3613   my $select;
3614   my $count_query;
3615   if ( $params->{'select_zip5'} ) {
3616     my $zip = 'COALESCE(cust_location.zip, cust_main.ship_zip, cust_main.zip)';
3617
3618     $select = "DISTINCT substr($zip,1,5) as zip";
3619     $orderby = "ORDER BY substr($zip,1,5)";
3620     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
3621   } else {
3622     $select = join(', ',
3623                          'cust_pkg.*',
3624                          ( map "part_pkg.$_", qw( pkg freq ) ),
3625                          'pkg_class.classname',
3626                          'cust_main.custnum AS cust_main_custnum',
3627                          FS::UI::Web::cust_sql_fields(
3628                            $params->{'cust_fields'}
3629                          ),
3630                   );
3631     $count_query = 'SELECT COUNT(*)';
3632   }
3633
3634   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
3635
3636   my $sql_query = {
3637     'table'       => 'cust_pkg',
3638     'hashref'     => {},
3639     'select'      => $select,
3640     'extra_sql'   => $extra_sql,
3641     'order_by'    => $orderby,
3642     'addl_from'   => $addl_from,
3643     'count_query' => $count_query,
3644   };
3645
3646 }
3647
3648 =item fcc_477_count
3649
3650 Returns a list of two package counts.  The first is a count of packages
3651 based on the supplied criteria and the second is the count of residential
3652 packages with those same criteria.  Criteria are specified as in the search
3653 method.
3654
3655 =cut
3656
3657 sub fcc_477_count {
3658   my ($class, $params) = @_;
3659
3660   my $sql_query = $class->search( $params );
3661
3662   my $count_sql = delete($sql_query->{'count_query'});
3663   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
3664     or die "couldn't parse count_sql";
3665
3666   my $count_sth = dbh->prepare($count_sql)
3667     or die "Error preparing $count_sql: ". dbh->errstr;
3668   $count_sth->execute
3669     or die "Error executing $count_sql: ". $count_sth->errstr;
3670   my $count_arrayref = $count_sth->fetchrow_arrayref;
3671
3672   return ( @$count_arrayref );
3673
3674 }
3675
3676
3677 =item location_sql
3678
3679 Returns a list: the first item is an SQL fragment identifying matching 
3680 packages/customers via location (taking into account shipping and package
3681 address taxation, if enabled), and subsequent items are the parameters to
3682 substitute for the placeholders in that fragment.
3683
3684 =cut
3685
3686 sub location_sql {
3687   my($class, %opt) = @_;
3688   my $ornull = $opt{'ornull'};
3689
3690   my $conf = new FS::Conf;
3691
3692   # '?' placeholders in _location_sql_where
3693   my $x = $ornull ? 3 : 2;
3694   my @bill_param = ( 
3695     ('district')x3,
3696     ('city')x3, 
3697     ('county')x$x,
3698     ('state')x$x,
3699     'country'
3700   );
3701
3702   my $main_where;
3703   my @main_param;
3704   if ( $conf->exists('tax-ship_address') ) {
3705
3706     $main_where = "(
3707          (     ( ship_last IS NULL     OR  ship_last  = '' )
3708            AND ". _location_sql_where('cust_main', '', $ornull ). "
3709          )
3710       OR (       ship_last IS NOT NULL AND ship_last != ''
3711            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
3712          )
3713     )";
3714     #    AND payby != 'COMP'
3715
3716     @main_param = ( @bill_param, @bill_param );
3717
3718   } else {
3719
3720     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
3721     @main_param = @bill_param;
3722
3723   }
3724
3725   my $where;
3726   my @param;
3727   if ( $conf->exists('tax-pkg_address') ) {
3728
3729     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
3730
3731     $where = " (
3732                     ( cust_pkg.locationnum IS     NULL AND $main_where )
3733                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
3734                )
3735              ";
3736     @param = ( @main_param, @bill_param );
3737   
3738   } else {
3739
3740     $where = $main_where;
3741     @param = @main_param;
3742
3743   }
3744
3745   ( $where, @param );
3746
3747 }
3748
3749 #subroutine, helper for location_sql
3750 sub _location_sql_where {
3751   my $table  = shift;
3752   my $prefix = @_ ? shift : '';
3753   my $ornull = @_ ? shift : '';
3754
3755 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
3756
3757   $ornull = $ornull ? ' OR ? IS NULL ' : '';
3758
3759   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
3760   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
3761   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
3762
3763   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
3764
3765 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
3766   "
3767         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
3768     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
3769     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
3770     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
3771     AND   $table.${prefix}country  = ?
3772   ";
3773 }
3774
3775 sub _X_show_zero {
3776   my( $self, $what ) = @_;
3777
3778   my $what_show_zero = $what. '_show_zero';
3779   length($self->$what_show_zero())
3780     ? ($self->$what_show_zero() eq 'Y')
3781     : $self->part_pkg->$what_show_zero();
3782 }
3783
3784 =head1 SUBROUTINES
3785
3786 =over 4
3787
3788 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
3789
3790 CUSTNUM is a customer (see L<FS::cust_main>)
3791
3792 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3793 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
3794 permitted.
3795
3796 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
3797 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
3798 new billing items.  An error is returned if this is not possible (see
3799 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
3800 parameter.
3801
3802 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3803 newly-created cust_pkg objects.
3804
3805 REFNUM, if specified, will specify the FS::pkg_referral record to be created
3806 and inserted.  Multiple FS::pkg_referral records can be created by
3807 setting I<refnum> to an array reference of refnums or a hash reference with
3808 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
3809 record will be created corresponding to cust_main.refnum.
3810
3811 =cut
3812
3813 sub order {
3814   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
3815
3816   my $conf = new FS::Conf;
3817
3818   # Transactionize this whole mess
3819   local $SIG{HUP} = 'IGNORE';
3820   local $SIG{INT} = 'IGNORE'; 
3821   local $SIG{QUIT} = 'IGNORE';
3822   local $SIG{TERM} = 'IGNORE';
3823   local $SIG{TSTP} = 'IGNORE'; 
3824   local $SIG{PIPE} = 'IGNORE'; 
3825
3826   my $oldAutoCommit = $FS::UID::AutoCommit;
3827   local $FS::UID::AutoCommit = 0;
3828   my $dbh = dbh;
3829
3830   my $error;
3831 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
3832 #  return "Customer not found: $custnum" unless $cust_main;
3833
3834   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
3835     if $DEBUG;
3836
3837   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3838                          @$remove_pkgnum;
3839
3840   my $change = scalar(@old_cust_pkg) != 0;
3841
3842   my %hash = (); 
3843   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
3844
3845     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
3846          " to pkgpart ". $pkgparts->[0]. "\n"
3847       if $DEBUG;
3848
3849     my $err_or_cust_pkg =
3850       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
3851                                 'refnum'  => $refnum,
3852                               );
3853
3854     unless (ref($err_or_cust_pkg)) {
3855       $dbh->rollback if $oldAutoCommit;
3856       return $err_or_cust_pkg;
3857     }
3858
3859     push @$return_cust_pkg, $err_or_cust_pkg;
3860     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3861     return '';
3862
3863   }
3864
3865   # Create the new packages.
3866   foreach my $pkgpart (@$pkgparts) {
3867
3868     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
3869
3870     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
3871                                       pkgpart => $pkgpart,
3872                                       refnum  => $refnum,
3873                                       %hash,
3874                                     };
3875     $error = $cust_pkg->insert( 'change' => $change );
3876     if ($error) {
3877       $dbh->rollback if $oldAutoCommit;
3878       return $error;
3879     }
3880     push @$return_cust_pkg, $cust_pkg;
3881   }
3882   # $return_cust_pkg now contains refs to all of the newly 
3883   # created packages.
3884
3885   # Transfer services and cancel old packages.
3886   foreach my $old_pkg (@old_cust_pkg) {
3887
3888     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
3889       if $DEBUG;
3890
3891     foreach my $new_pkg (@$return_cust_pkg) {
3892       $error = $old_pkg->transfer($new_pkg);
3893       if ($error and $error == 0) {
3894         # $old_pkg->transfer failed.
3895         $dbh->rollback if $oldAutoCommit;
3896         return $error;
3897       }
3898     }
3899
3900     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
3901       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
3902       foreach my $new_pkg (@$return_cust_pkg) {
3903         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
3904         if ($error and $error == 0) {
3905           # $old_pkg->transfer failed.
3906         $dbh->rollback if $oldAutoCommit;
3907         return $error;
3908         }
3909       }
3910     }
3911
3912     if ($error > 0) {
3913       # Transfers were successful, but we went through all of the 
3914       # new packages and still had services left on the old package.
3915       # We can't cancel the package under the circumstances, so abort.
3916       $dbh->rollback if $oldAutoCommit;
3917       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
3918     }
3919     $error = $old_pkg->cancel( quiet=>1 );
3920     if ($error) {
3921       $dbh->rollback;
3922       return $error;
3923     }
3924   }
3925   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3926   '';
3927 }
3928
3929 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
3930
3931 A bulk change method to change packages for multiple customers.
3932
3933 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3934 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
3935 permitted.
3936
3937 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
3938 replace.  The services (see L<FS::cust_svc>) are moved to the
3939 new billing items.  An error is returned if this is not possible (see
3940 L<FS::pkg_svc>).
3941
3942 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3943 newly-created cust_pkg objects.
3944
3945 =cut
3946
3947 sub bulk_change {
3948   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
3949
3950   # Transactionize this whole mess
3951   local $SIG{HUP} = 'IGNORE';
3952   local $SIG{INT} = 'IGNORE'; 
3953   local $SIG{QUIT} = 'IGNORE';
3954   local $SIG{TERM} = 'IGNORE';
3955   local $SIG{TSTP} = 'IGNORE'; 
3956   local $SIG{PIPE} = 'IGNORE'; 
3957
3958   my $oldAutoCommit = $FS::UID::AutoCommit;
3959   local $FS::UID::AutoCommit = 0;
3960   my $dbh = dbh;
3961
3962   my @errors;
3963   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3964                          @$remove_pkgnum;
3965
3966   while(scalar(@old_cust_pkg)) {
3967     my @return = ();
3968     my $custnum = $old_cust_pkg[0]->custnum;
3969     my (@remove) = map { $_->pkgnum }
3970                    grep { $_->custnum == $custnum } @old_cust_pkg;
3971     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
3972
3973     my $error = order $custnum, $pkgparts, \@remove, \@return;
3974
3975     push @errors, $error
3976       if $error;
3977     push @$return_cust_pkg, @return;
3978   }
3979
3980   if (scalar(@errors)) {
3981     $dbh->rollback if $oldAutoCommit;
3982     return join(' / ', @errors);
3983   }
3984
3985   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3986   '';
3987 }
3988
3989 # Used by FS::Upgrade to migrate to a new database.
3990 sub _upgrade_data {  # class method
3991   my ($class, %opts) = @_;
3992   $class->_upgrade_otaker(%opts);
3993   my @statements = (
3994     # RT#10139, bug resulting in contract_end being set when it shouldn't
3995   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
3996     # RT#10830, bad calculation of prorate date near end of year
3997     # the date range for bill is December 2009, and we move it forward
3998     # one year if it's before the previous bill date (which it should 
3999     # never be)
4000   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
4001   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
4002   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
4003     # RT6628, add order_date to cust_pkg
4004     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
4005         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
4006         history_action = \'insert\') where order_date is null',
4007   );
4008   foreach my $sql (@statements) {
4009     my $sth = dbh->prepare($sql);
4010     $sth->execute or die $sth->errstr;
4011   }
4012 }
4013
4014 =back
4015
4016 =head1 BUGS
4017
4018 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
4019
4020 In sub order, the @pkgparts array (passed by reference) is clobbered.
4021
4022 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
4023 method to pass dates to the recur_prog expression, it should do so.
4024
4025 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
4026 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
4027 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
4028 configuration values.  Probably need a subroutine which decides what to do
4029 based on whether or not we've fetched the user yet, rather than a hash.  See
4030 FS::UID and the TODO.
4031
4032 Now that things are transactional should the check in the insert method be
4033 moved to check ?
4034
4035 =head1 SEE ALSO
4036
4037 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
4038 L<FS::pkg_svc>, schema.html from the base documentation
4039
4040 =cut
4041
4042 1;
4043