backport cust_svc_unsorted method from master, RT#26097
[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 =item cust_svc_unsorted [ OPTION => VALUE ... ] 
2036
2037 Returns the services for this package, as FS::cust_svc objects (see
2038 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
2039 spcififed, returns only the matching services.
2040
2041 As an optimization, use the cust_svc_unsorted version if you are not displaying
2042 the results.
2043
2044 =cut
2045
2046 sub cust_svc {
2047   my $self = shift;
2048   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
2049   $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref(@_) );
2050 }
2051
2052 sub cust_svc_unsorted {
2053   my $self = shift;
2054   @{ $self->cust_svc_unsorted_arrayref(@_) };
2055 }
2056
2057 sub cust_svc_unsorted_arrayref {
2058   my $self = shift;
2059
2060   return () unless $self->num_cust_svc(@_);
2061
2062   my %opt = ();
2063   if ( @_ && $_[0] =~ /^\d+/ ) {
2064     $opt{svcpart} = shift;
2065   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2066     %opt = %{ $_[0] };
2067   } elsif ( @_ ) {
2068     %opt = @_;
2069   }
2070
2071   my %search = (
2072     'table'   => 'cust_svc',
2073     'hashref' => { 'pkgnum' => $self->pkgnum },
2074   );
2075   if ( $opt{svcpart} ) {
2076     $search{hashref}->{svcpart} = $opt{'svcpart'};
2077   }
2078   if ( $opt{'svcdb'} ) {
2079     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
2080     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
2081   }
2082
2083   [ qsearch(\%search) ];
2084
2085 }
2086
2087 =item overlimit [ SVCPART ]
2088
2089 Returns the services for this package which have exceeded their
2090 usage limit as FS::cust_svc objects (see L<FS::cust_svc>).  If a svcpart
2091 is specified, return only the matching services.
2092
2093 =cut
2094
2095 sub overlimit {
2096   my $self = shift;
2097   return () unless $self->num_cust_svc(@_);
2098   grep { $_->overlimit } $self->cust_svc(@_);
2099 }
2100
2101 =item h_cust_svc END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2102
2103 Returns historical services for this package created before END TIMESTAMP and
2104 (optionally) not cancelled before START_TIMESTAMP, as FS::h_cust_svc objects
2105 (see L<FS::h_cust_svc>).  If MODE is 'I' (for 'invoice'), services with the 
2106 I<pkg_svc.hidden> flag will be omitted.
2107
2108 =cut
2109
2110 sub h_cust_svc {
2111   my $self = shift;
2112   warn "$me _h_cust_svc called on $self\n"
2113     if $DEBUG;
2114
2115   my ($end, $start, $mode) = @_;
2116   my @cust_svc = $self->_sort_cust_svc(
2117     [ qsearch( 'h_cust_svc',
2118       { 'pkgnum' => $self->pkgnum, },  
2119       FS::h_cust_svc->sql_h_search(@_),  
2120     ) ]
2121   );
2122   if ( defined($mode) && $mode eq 'I' ) {
2123     my %hidden_svcpart = map { $_->svcpart => $_->hidden } $self->part_svc;
2124     return grep { !$hidden_svcpart{$_->svcpart} } @cust_svc;
2125   } else {
2126     return @cust_svc;
2127   }
2128 }
2129
2130 sub _sort_cust_svc {
2131   my( $self, $arrayref ) = @_;
2132
2133   my $sort =
2134     sub ($$) { my ($a, $b) = @_; $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] };
2135
2136   map  { $_->[0] }
2137   sort $sort
2138   map {
2139         my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $self->pkgpart,
2140                                              'svcpart' => $_->svcpart     } );
2141         [ $_,
2142           $pkg_svc ? $pkg_svc->primary_svc : '',
2143           $pkg_svc ? $pkg_svc->quantity : 0,
2144         ];
2145       }
2146   @$arrayref;
2147
2148 }
2149
2150 =item num_cust_svc [ SVCPART ] (old, deprecated usage)
2151
2152 =item num_cust_svc [ OPTION => VALUE ... ] (current usage)
2153
2154 Returns the number of services for this package.  Available options are svcpart
2155 and svcdb.  If either is spcififed, returns only the matching services.
2156
2157 =cut
2158
2159 sub num_cust_svc {
2160   my $self = shift;
2161
2162   return $self->{'_num_cust_svc'}
2163     if !scalar(@_)
2164        && exists($self->{'_num_cust_svc'})
2165        && $self->{'_num_cust_svc'} =~ /\d/;
2166
2167   cluck "cust_pkg->num_cust_svc called, _num_cust_svc:".$self->{'_num_cust_svc'}
2168     if $DEBUG > 2;
2169
2170   my %opt = ();
2171   if ( @_ && $_[0] =~ /^\d+/ ) {
2172     $opt{svcpart} = shift;
2173   } elsif ( @_ && ref($_[0]) eq 'HASH' ) {
2174     %opt = %{ $_[0] };
2175   } elsif ( @_ ) {
2176     %opt = @_;
2177   }
2178
2179   my $select = 'SELECT COUNT(*) FROM cust_svc ';
2180   my $where = ' WHERE pkgnum = ? ';
2181   my @param = ($self->pkgnum);
2182
2183   if ( $opt{'svcpart'} ) {
2184     $where .= ' AND svcpart = ? ';
2185     push @param, $opt{'svcpart'};
2186   }
2187   if ( $opt{'svcdb'} ) {
2188     $select .= ' LEFT JOIN part_svc USING ( svcpart ) ';
2189     $where .= ' AND svcdb = ? ';
2190     push @param, $opt{'svcdb'};
2191   }
2192
2193   my $sth = dbh->prepare("$select $where") or die  dbh->errstr;
2194   $sth->execute(@param) or die $sth->errstr;
2195   $sth->fetchrow_arrayref->[0];
2196 }
2197
2198 =item available_part_svc 
2199
2200 Returns a list of FS::part_svc objects representing services included in this
2201 package but not yet provisioned.  Each FS::part_svc object also has an extra
2202 field, I<num_avail>, which specifies the number of available services.
2203
2204 =cut
2205
2206 sub available_part_svc {
2207   my $self = shift;
2208   grep { $_->num_avail > 0 }
2209     map {
2210           my $part_svc = $_->part_svc;
2211           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
2212             $_->quantity - $self->num_cust_svc($_->svcpart);
2213
2214           # more evil encapsulation breakage
2215           if($part_svc->{'Hash'}{'num_avail'} > 0) {
2216             my @exports = $part_svc->part_export_did;
2217             $part_svc->{'Hash'}{'can_get_dids'} = scalar(@exports);
2218           }
2219
2220           $part_svc;
2221         }
2222       $self->part_pkg->pkg_svc;
2223 }
2224
2225 =item part_svc [ OPTION => VALUE ... ]
2226
2227 Returns a list of FS::part_svc objects representing provisioned and available
2228 services included in this package.  Each FS::part_svc object also has the
2229 following extra fields:
2230
2231 =over 4
2232
2233 =item num_cust_svc  (count)
2234
2235 =item num_avail     (quantity - count)
2236
2237 =item cust_pkg_svc (services) - array reference containing the provisioned services, as cust_svc objects
2238
2239 =back
2240
2241 Accepts one option: summarize_size.  If specified and non-zero, will omit the
2242 extra cust_pkg_svc option for objects where num_cust_svc is this size or
2243 greater.
2244
2245 =cut
2246
2247 #svcnum
2248 #label -> ($cust_svc->label)[1]
2249
2250 sub part_svc {
2251   my $self = shift;
2252   my %opt = @_;
2253
2254   #XXX some sort of sort order besides numeric by svcpart...
2255   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
2256     my $pkg_svc = $_;
2257     my $part_svc = $pkg_svc->part_svc;
2258     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2259     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
2260     $part_svc->{'Hash'}{'num_avail'}    =
2261       max( 0, $pkg_svc->quantity - $num_cust_svc );
2262     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2263         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
2264       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
2265           && $num_cust_svc >= $opt{summarize_size};
2266     $part_svc->{'Hash'}{'hidden'} = $pkg_svc->hidden;
2267     $part_svc;
2268   } $self->part_pkg->pkg_svc;
2269
2270   #extras
2271   push @part_svc, map {
2272     my $part_svc = $_;
2273     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
2274     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #speak no evail
2275     $part_svc->{'Hash'}{'num_avail'}    = 0; #0-$num_cust_svc ?
2276     $part_svc->{'Hash'}{'cust_pkg_svc'} =
2277       $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : [];
2278     $part_svc;
2279   } $self->extra_part_svc;
2280
2281   @part_svc;
2282
2283 }
2284
2285 =item extra_part_svc
2286
2287 Returns a list of FS::part_svc objects corresponding to services in this
2288 package which are still provisioned but not (any longer) available in the
2289 package definition.
2290
2291 =cut
2292
2293 sub extra_part_svc {
2294   my $self = shift;
2295
2296   my $pkgnum  = $self->pkgnum;
2297   #my $pkgpart = $self->pkgpart;
2298
2299 #  qsearch( {
2300 #    'table'     => 'part_svc',
2301 #    'hashref'   => {},
2302 #    'extra_sql' =>
2303 #      "WHERE 0 = ( SELECT COUNT(*) FROM pkg_svc 
2304 #                     WHERE pkg_svc.svcpart = part_svc.svcpart 
2305 #                       AND pkg_svc.pkgpart = ?
2306 #                       AND quantity > 0 
2307 #                 )
2308 #        AND 0 < ( SELECT COUNT(*) FROM cust_svc
2309 #                       LEFT JOIN cust_pkg USING ( pkgnum )
2310 #                     WHERE cust_svc.svcpart = part_svc.svcpart
2311 #                       AND pkgnum = ?
2312 #                 )",
2313 #    'extra_param' => [ [$self->pkgpart=>'int'], [$self->pkgnum=>'int'] ],
2314 #  } );
2315
2316 #seems to benchmark slightly faster... (or did?)
2317
2318   my @pkgparts = map $_->pkgpart, $self->part_pkg->self_and_svc_linked;
2319   my $pkgparts = join(',', @pkgparts);
2320
2321   qsearch( {
2322     #'select'      => 'DISTINCT ON (svcpart) part_svc.*',
2323     #MySQL doesn't grok DISINCT ON
2324     'select'      => 'DISTINCT part_svc.*',
2325     'table'       => 'part_svc',
2326     'addl_from'   =>
2327       "LEFT JOIN pkg_svc  ON (     pkg_svc.svcpart   = part_svc.svcpart 
2328                                AND pkg_svc.pkgpart IN ($pkgparts)
2329                                AND quantity > 0
2330                              )
2331        LEFT JOIN cust_svc ON (     cust_svc.svcpart = part_svc.svcpart )
2332        LEFT JOIN cust_pkg USING ( pkgnum )
2333       ",
2334     'hashref'     => {},
2335     'extra_sql'   => "WHERE pkgsvcnum IS NULL AND cust_pkg.pkgnum = ? ",
2336     'extra_param' => [ [$self->pkgnum=>'int'] ],
2337   } );
2338 }
2339
2340 =item status
2341
2342 Returns a short status string for this package, currently:
2343
2344 =over 4
2345
2346 =item not yet billed
2347
2348 =item one-time charge
2349
2350 =item active
2351
2352 =item suspended
2353
2354 =item cancelled
2355
2356 =back
2357
2358 =cut
2359
2360 sub status {
2361   my $self = shift;
2362
2363   my $freq = length($self->freq) ? $self->freq : $self->part_pkg->freq;
2364
2365   return 'cancelled' if $self->get('cancel');
2366   return 'suspended' if $self->susp;
2367   return 'not yet billed' unless $self->setup;
2368   return 'one-time charge' if $freq =~ /^(0|$)/;
2369   return 'active';
2370 }
2371
2372 =item ucfirst_status
2373
2374 Returns the status with the first character capitalized.
2375
2376 =cut
2377
2378 sub ucfirst_status {
2379   ucfirst(shift->status);
2380 }
2381
2382 =item statuses
2383
2384 Class method that returns the list of possible status strings for packages
2385 (see L<the status method|/status>).  For example:
2386
2387   @statuses = FS::cust_pkg->statuses();
2388
2389 =cut
2390
2391 tie my %statuscolor, 'Tie::IxHash', 
2392   'not yet billed'  => '009999', #teal? cyan?
2393   'one-time charge' => '000000',
2394   'active'          => '00CC00',
2395   'suspended'       => 'FF9900',
2396   'cancelled'       => 'FF0000',
2397 ;
2398
2399 sub statuses {
2400   my $self = shift; #could be class...
2401   #grep { $_ !~ /^(not yet billed)$/ } #this is a dumb status anyway
2402   #                                    # mayble split btw one-time vs. recur
2403     keys %statuscolor;
2404 }
2405
2406 =item statuscolor
2407
2408 Returns a hex triplet color string for this package's status.
2409
2410 =cut
2411
2412 sub statuscolor {
2413   my $self = shift;
2414   $statuscolor{$self->status};
2415 }
2416
2417 =item pkg_label
2418
2419 Returns a label for this package.  (Currently "pkgnum: pkg - comment" or
2420 "pkg-comment" depending on user preference).
2421
2422 =cut
2423
2424 sub pkg_label {
2425   my $self = shift;
2426   my $label = $self->part_pkg->pkg_comment( 'nopkgpart' => 1 );
2427   $label = $self->pkgnum. ": $label"
2428     if $FS::CurrentUser::CurrentUser->option('show_pkgnum');
2429   $label;
2430 }
2431
2432 =item pkg_label_long
2433
2434 Returns a long label for this package, adding the primary service's label to
2435 pkg_label.
2436
2437 =cut
2438
2439 sub pkg_label_long {
2440   my $self = shift;
2441   my $label = $self->pkg_label;
2442   my $cust_svc = $self->primary_cust_svc;
2443   $label .= ' ('. ($cust_svc->label)[1]. ')' if $cust_svc;
2444   $label;
2445 }
2446
2447 =item primary_cust_svc
2448
2449 Returns a primary service (as FS::cust_svc object) if one can be identified.
2450
2451 =cut
2452
2453 #for labeling purposes - might not 100% match up with part_pkg->svcpart's idea
2454
2455 sub primary_cust_svc {
2456   my $self = shift;
2457
2458   my @cust_svc = $self->cust_svc;
2459
2460   return '' unless @cust_svc; #no serivces - irrelevant then
2461   
2462   return $cust_svc[0] if scalar(@cust_svc) == 1; #always return a single service
2463
2464   # primary service as specified in the package definition
2465   # or exactly one service definition with quantity one
2466   my $svcpart = $self->part_pkg->svcpart;
2467   @cust_svc = grep { $_->svcpart == $svcpart } @cust_svc;
2468   return $cust_svc[0] if scalar(@cust_svc) == 1;
2469
2470   #couldn't identify one thing..
2471   return '';
2472 }
2473
2474 =item labels
2475
2476 Returns a list of lists, calling the label method for all services
2477 (see L<FS::cust_svc>) of this billing item.
2478
2479 =cut
2480
2481 sub labels {
2482   my $self = shift;
2483   map { [ $_->label ] } $self->cust_svc;
2484 }
2485
2486 =item h_labels END_TIMESTAMP [ START_TIMESTAMP ] [ MODE ]
2487
2488 Like the labels method, but returns historical information on services that
2489 were active as of END_TIMESTAMP and (optionally) not cancelled before
2490 START_TIMESTAMP.  If MODE is 'I' (for 'invoice'), services with the 
2491 I<pkg_svc.hidden> flag will be omitted.
2492
2493 Returns a list of lists, calling the label method for all (historical) services
2494 (see L<FS::h_cust_svc>) of this billing item.
2495
2496 =cut
2497
2498 sub h_labels {
2499   my $self = shift;
2500   warn "$me _h_labels called on $self\n"
2501     if $DEBUG;
2502   map { [ $_->label(@_) ] } $self->h_cust_svc(@_);
2503 }
2504
2505 =item labels_short
2506
2507 Like labels, except returns a simple flat list, and shortens long
2508 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2509 identical services to one line that lists the service label and the number of
2510 individual services rather than individual items.
2511
2512 =cut
2513
2514 sub labels_short {
2515   shift->_labels_short( 'labels', @_ );
2516 }
2517
2518 =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ]
2519
2520 Like h_labels, except returns a simple flat list, and shortens long
2521 (currently >5 or the cust_bill-max_same_services configuration value) lists of
2522 identical services to one line that lists the service label and the number of
2523 individual services rather than individual items.
2524
2525 =cut
2526
2527 sub h_labels_short {
2528   shift->_labels_short( 'h_labels', @_ );
2529 }
2530
2531 sub _labels_short {
2532   my( $self, $method ) = ( shift, shift );
2533
2534   warn "$me _labels_short called on $self with $method method\n"
2535     if $DEBUG;
2536
2537   my $conf = new FS::Conf;
2538   my $max_same_services = $conf->config('cust_bill-max_same_services') || 5;
2539
2540   warn "$me _labels_short populating \%labels\n"
2541     if $DEBUG;
2542
2543   my %labels;
2544   #tie %labels, 'Tie::IxHash';
2545   push @{ $labels{$_->[0]} }, $_->[1]
2546     foreach $self->$method(@_);
2547
2548   warn "$me _labels_short populating \@labels\n"
2549     if $DEBUG;
2550
2551   my @labels;
2552   foreach my $label ( keys %labels ) {
2553     my %seen = ();
2554     my @values = grep { ! $seen{$_}++ } @{ $labels{$label} };
2555     my $num = scalar(@values);
2556     warn "$me _labels_short $num items for $label\n"
2557       if $DEBUG;
2558
2559     if ( $num > $max_same_services ) {
2560       warn "$me _labels_short   more than $max_same_services, so summarizing\n"
2561         if $DEBUG;
2562       push @labels, "$label ($num)";
2563     } else {
2564       if ( $conf->exists('cust_bill-consolidate_services') ) {
2565         warn "$me _labels_short   consolidating services\n"
2566           if $DEBUG;
2567         # push @labels, "$label: ". join(', ', @values);
2568         while ( @values ) {
2569           my $detail = "$label: ";
2570           $detail .= shift(@values). ', '
2571             while @values
2572                && ( length($detail.$values[0]) < 78 || $detail eq "$label: " );
2573           $detail =~ s/, $//;
2574           push @labels, $detail;
2575         }
2576         warn "$me _labels_short   done consolidating services\n"
2577           if $DEBUG;
2578       } else {
2579         warn "$me _labels_short   adding service data\n"
2580           if $DEBUG;
2581         push @labels, map { "$label: $_" } @values;
2582       }
2583     }
2584   }
2585
2586  @labels;
2587
2588 }
2589
2590 =item cust_main
2591
2592 Returns the parent customer object (see L<FS::cust_main>).
2593
2594 =cut
2595
2596 sub cust_main {
2597   my $self = shift;
2598   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
2599 }
2600
2601 =item balance
2602
2603 Returns the balance for this specific package, when using
2604 experimental package balance.
2605
2606 =cut
2607
2608 sub balance {
2609   my $self = shift;
2610   $self->cust_main->balance_pkgnum( $self->pkgnum );
2611 }
2612
2613 #these subs are in location_Mixin.pm now... unfortunately the POD doesn't mixin
2614
2615 =item cust_location
2616
2617 Returns the location object, if any (see L<FS::cust_location>).
2618
2619 =item cust_location_or_main
2620
2621 If this package is associated with a location, returns the locaiton (see
2622 L<FS::cust_location>), otherwise returns the customer (see L<FS::cust_main>).
2623
2624 =item location_label [ OPTION => VALUE ... ]
2625
2626 Returns the label of the location object (see L<FS::cust_location>).
2627
2628 =cut
2629
2630 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
2631
2632 =item seconds_since TIMESTAMP
2633
2634 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
2635 package have been online since TIMESTAMP, according to the session monitor.
2636
2637 TIMESTAMP is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
2638 L<Time::Local> and L<Date::Parse> for conversion functions.
2639
2640 =cut
2641
2642 sub seconds_since {
2643   my($self, $since) = @_;
2644   my $seconds = 0;
2645
2646   foreach my $cust_svc (
2647     grep { $_->part_svc->svcdb eq 'svc_acct' } $self->cust_svc
2648   ) {
2649     $seconds += $cust_svc->seconds_since($since);
2650   }
2651
2652   $seconds;
2653
2654 }
2655
2656 =item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
2657
2658 Returns the numbers of seconds all accounts (see L<FS::svc_acct>) in this
2659 package have been online between TIMESTAMP_START (inclusive) and TIMESTAMP_END
2660 (exclusive).
2661
2662 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2663 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2664 functions.
2665
2666
2667 =cut
2668
2669 sub seconds_since_sqlradacct {
2670   my($self, $start, $end) = @_;
2671
2672   my $seconds = 0;
2673
2674   foreach my $cust_svc (
2675     grep {
2676       my $part_svc = $_->part_svc;
2677       $part_svc->svcdb eq 'svc_acct'
2678         && scalar($part_svc->part_export_usage);
2679     } $self->cust_svc
2680   ) {
2681     $seconds += $cust_svc->seconds_since_sqlradacct($start, $end);
2682   }
2683
2684   $seconds;
2685
2686 }
2687
2688 =item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
2689
2690 Returns the sum of the given attribute for all accounts (see L<FS::svc_acct>)
2691 in this package for sessions ending between TIMESTAMP_START (inclusive) and
2692 TIMESTAMP_END
2693 (exclusive).
2694
2695 TIMESTAMP_START and TIMESTAMP_END are specified as UNIX timestamps; see
2696 L<perlfunc/"time">.  Also see L<Time::Local> and L<Date::Parse> for conversion
2697 functions.
2698
2699 =cut
2700
2701 sub attribute_since_sqlradacct {
2702   my($self, $start, $end, $attrib) = @_;
2703
2704   my $sum = 0;
2705
2706   foreach my $cust_svc (
2707     grep {
2708       my $part_svc = $_->part_svc;
2709       $part_svc->svcdb eq 'svc_acct'
2710         && scalar($part_svc->part_export_usage);
2711     } $self->cust_svc
2712   ) {
2713     $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib);
2714   }
2715
2716   $sum;
2717
2718 }
2719
2720 =item quantity
2721
2722 =cut
2723
2724 sub quantity {
2725   my( $self, $value ) = @_;
2726   if ( defined($value) ) {
2727     $self->setfield('quantity', $value);
2728   }
2729   $self->getfield('quantity') || 1;
2730 }
2731
2732 =item transfer DEST_PKGNUM | DEST_CUST_PKG, [ OPTION => VALUE ... ]
2733
2734 Transfers as many services as possible from this package to another package.
2735
2736 The destination package can be specified by pkgnum by passing an FS::cust_pkg
2737 object.  The destination package must already exist.
2738
2739 Services are moved only if the destination allows services with the correct
2740 I<svcpart> (not svcdb), unless the B<change_svcpart> option is set true.  Use
2741 this option with caution!  No provision is made for export differences
2742 between the old and new service definitions.  Probably only should be used
2743 when your exports for all service definitions of a given svcdb are identical.
2744 (attempt a transfer without it first, to move all possible svcpart-matching
2745 services)
2746
2747 Any services that can't be moved remain in the original package.
2748
2749 Returns an error, if there is one; otherwise, returns the number of services 
2750 that couldn't be moved.
2751
2752 =cut
2753
2754 sub transfer {
2755   my ($self, $dest_pkgnum, %opt) = @_;
2756
2757   my $remaining = 0;
2758   my $dest;
2759   my %target;
2760
2761   if (ref ($dest_pkgnum) eq 'FS::cust_pkg') {
2762     $dest = $dest_pkgnum;
2763     $dest_pkgnum = $dest->pkgnum;
2764   } else {
2765     $dest = qsearchs('cust_pkg', { pkgnum => $dest_pkgnum });
2766   }
2767
2768   return ('Package does not exist: '.$dest_pkgnum) unless $dest;
2769
2770   foreach my $pkg_svc ( $dest->part_pkg->pkg_svc ) {
2771     $target{$pkg_svc->svcpart} = $pkg_svc->quantity;
2772   }
2773
2774   foreach my $cust_svc ($dest->cust_svc) {
2775     $target{$cust_svc->svcpart}--;
2776   }
2777
2778   my %svcpart2svcparts = ();
2779   if ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2780     warn "change_svcpart option received, creating alternates list\n" if $DEBUG;
2781     foreach my $svcpart ( map { $_->svcpart } $self->cust_svc ) {
2782       next if exists $svcpart2svcparts{$svcpart};
2783       my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
2784       $svcpart2svcparts{$svcpart} = [
2785         map  { $_->[0] }
2786         sort { $b->[1] cmp $a->[1]  or  $a->[2] <=> $b->[2] } 
2787         map {
2788               my $pkg_svc = qsearchs( 'pkg_svc', { 'pkgpart' => $dest->pkgpart,
2789                                                    'svcpart' => $_          } );
2790               [ $_,
2791                 $pkg_svc ? $pkg_svc->primary_svc : '',
2792                 $pkg_svc ? $pkg_svc->quantity : 0,
2793               ];
2794             }
2795
2796         grep { $_ != $svcpart }
2797         map  { $_->svcpart }
2798         qsearch('part_svc', { 'svcdb' => $part_svc->svcdb } )
2799       ];
2800       warn "alternates for svcpart $svcpart: ".
2801            join(', ', @{$svcpart2svcparts{$svcpart}}). "\n"
2802         if $DEBUG;
2803     }
2804   }
2805
2806   foreach my $cust_svc ($self->cust_svc) {
2807     if($target{$cust_svc->svcpart} > 0) {
2808       $target{$cust_svc->svcpart}--;
2809       my $new = new FS::cust_svc { $cust_svc->hash };
2810       $new->pkgnum($dest_pkgnum);
2811       my $error = $new->replace($cust_svc);
2812       return $error if $error;
2813     } elsif ( exists $opt{'change_svcpart'} && $opt{'change_svcpart'} ) {
2814       if ( $DEBUG ) {
2815         warn "looking for alternates for svcpart ". $cust_svc->svcpart. "\n";
2816         warn "alternates to consider: ".
2817              join(', ', @{$svcpart2svcparts{$cust_svc->svcpart}}). "\n";
2818       }
2819       my @alternate = grep {
2820                              warn "considering alternate svcpart $_: ".
2821                                   "$target{$_} available in new package\n"
2822                                if $DEBUG;
2823                              $target{$_} > 0;
2824                            } @{$svcpart2svcparts{$cust_svc->svcpart}};
2825       if ( @alternate ) {
2826         warn "alternate(s) found\n" if $DEBUG;
2827         my $change_svcpart = $alternate[0];
2828         $target{$change_svcpart}--;
2829         my $new = new FS::cust_svc { $cust_svc->hash };
2830         $new->svcpart($change_svcpart);
2831         $new->pkgnum($dest_pkgnum);
2832         my $error = $new->replace($cust_svc);
2833         return $error if $error;
2834       } else {
2835         $remaining++;
2836       }
2837     } else {
2838       $remaining++
2839     }
2840   }
2841   return $remaining;
2842 }
2843
2844 =item reexport
2845
2846 This method is deprecated.  See the I<depend_jobnum> option to the insert and
2847 order_pkgs methods in FS::cust_main for a better way to defer provisioning.
2848
2849 =cut
2850
2851 sub reexport {
2852   my $self = shift;
2853
2854   local $SIG{HUP} = 'IGNORE';
2855   local $SIG{INT} = 'IGNORE';
2856   local $SIG{QUIT} = 'IGNORE';
2857   local $SIG{TERM} = 'IGNORE';
2858   local $SIG{TSTP} = 'IGNORE';
2859   local $SIG{PIPE} = 'IGNORE';
2860
2861   my $oldAutoCommit = $FS::UID::AutoCommit;
2862   local $FS::UID::AutoCommit = 0;
2863   my $dbh = dbh;
2864
2865   foreach my $cust_svc ( $self->cust_svc ) {
2866     #false laziness w/svc_Common::insert
2867     my $svc_x = $cust_svc->svc_x;
2868     foreach my $part_export ( $cust_svc->part_svc->part_export ) {
2869       my $error = $part_export->export_insert($svc_x);
2870       if ( $error ) {
2871         $dbh->rollback if $oldAutoCommit;
2872         return $error;
2873       }
2874     }
2875   }
2876
2877   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2878   '';
2879
2880 }
2881
2882 =item export_pkg_change OLD_CUST_PKG
2883
2884 Calls the "pkg_change" export action for all services attached to this package.
2885
2886 =cut
2887
2888 sub export_pkg_change {
2889   my( $self, $old )  = ( shift, shift );
2890
2891   local $SIG{HUP} = 'IGNORE';
2892   local $SIG{INT} = 'IGNORE';
2893   local $SIG{QUIT} = 'IGNORE';
2894   local $SIG{TERM} = 'IGNORE';
2895   local $SIG{TSTP} = 'IGNORE';
2896   local $SIG{PIPE} = 'IGNORE';
2897
2898   my $oldAutoCommit = $FS::UID::AutoCommit;
2899   local $FS::UID::AutoCommit = 0;
2900   my $dbh = dbh;
2901
2902   foreach my $svc_x ( map $_->svc_x, $self->cust_svc ) {
2903     my $error = $svc_x->export('pkg_change', $self, $old);
2904     if ( $error ) {
2905       $dbh->rollback if $oldAutoCommit;
2906       return $error;
2907     }
2908   }
2909
2910   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2911   '';
2912
2913 }
2914
2915 =item insert_reason
2916
2917 Associates this package with a (suspension or cancellation) reason (see
2918 L<FS::cust_pkg_reason>, possibly inserting a new reason on the fly (see
2919 L<FS::reason>).
2920
2921 Available options are:
2922
2923 =over 4
2924
2925 =item reason
2926
2927 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.
2928
2929 =item reason_otaker
2930
2931 the access_user (see L<FS::access_user>) providing the reason
2932
2933 =item date
2934
2935 a unix timestamp 
2936
2937 =item action
2938
2939 the action (cancel, susp, adjourn, expire) associated with the reason
2940
2941 =back
2942
2943 If there is an error, returns the error, otherwise returns false.
2944
2945 =cut
2946
2947 sub insert_reason {
2948   my ($self, %options) = @_;
2949
2950   my $otaker = $options{reason_otaker} ||
2951                $FS::CurrentUser::CurrentUser->username;
2952
2953   my $reasonnum;
2954   if ( $options{'reason'} =~ /^(\d+)$/ ) {
2955
2956     $reasonnum = $1;
2957
2958   } elsif ( ref($options{'reason'}) ) {
2959   
2960     return 'Enter a new reason (or select an existing one)'
2961       unless $options{'reason'}->{'reason'} !~ /^\s*$/;
2962
2963     my $reason = new FS::reason({
2964       'reason_type' => $options{'reason'}->{'typenum'},
2965       'reason'      => $options{'reason'}->{'reason'},
2966     });
2967     my $error = $reason->insert;
2968     return $error if $error;
2969
2970     $reasonnum = $reason->reasonnum;
2971
2972   } else {
2973     return "Unparsable reason: ". $options{'reason'};
2974   }
2975
2976   my $cust_pkg_reason =
2977     new FS::cust_pkg_reason({ 'pkgnum'    => $self->pkgnum,
2978                               'reasonnum' => $reasonnum, 
2979                               'otaker'    => $otaker,
2980                               'action'    => substr(uc($options{'action'}),0,1),
2981                               'date'      => $options{'date'}
2982                                                ? $options{'date'}
2983                                                : time,
2984                             });
2985
2986   $cust_pkg_reason->insert;
2987 }
2988
2989 =item insert_discount
2990
2991 Associates this package with a discount (see L<FS::cust_pkg_discount>, possibly
2992 inserting a new discount on the fly (see L<FS::discount>).
2993
2994 Available options are:
2995
2996 =over 4
2997
2998 =item discountnum
2999
3000 =back
3001
3002 If there is an error, returns the error, otherwise returns false.
3003
3004 =cut
3005
3006 sub insert_discount {
3007   #my ($self, %options) = @_;
3008   my $self = shift;
3009
3010   my $cust_pkg_discount = new FS::cust_pkg_discount {
3011     'pkgnum'      => $self->pkgnum,
3012     'discountnum' => $self->discountnum,
3013     'months_used' => 0,
3014     'end_date'    => '', #XXX
3015     #for the create a new discount case
3016     '_type'       => $self->discountnum__type,
3017     'amount'      => $self->discountnum_amount,
3018     'percent'     => $self->discountnum_percent,
3019     'months'      => $self->discountnum_months,
3020     'setup'      => $self->discountnum_setup,
3021     #'disabled'    => $self->discountnum_disabled,
3022   };
3023
3024   $cust_pkg_discount->insert;
3025 }
3026
3027 =item set_usage USAGE_VALUE_HASHREF 
3028
3029 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3030 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3031 upbytes, downbytes, and totalbytes are appropriate keys.
3032
3033 All svc_accts which are part of this package have their values reset.
3034
3035 =cut
3036
3037 sub set_usage {
3038   my ($self, $valueref, %opt) = @_;
3039
3040   #only svc_acct can set_usage for now
3041   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3042     my $svc_x = $cust_svc->svc_x;
3043     $svc_x->set_usage($valueref, %opt)
3044       if $svc_x->can("set_usage");
3045   }
3046 }
3047
3048 =item recharge USAGE_VALUE_HASHREF 
3049
3050 USAGE_VALUE_HASHREF is a hashref of svc_acct usage columns and the amounts
3051 to which they should be set (see L<FS::svc_acct>).  Currently seconds,
3052 upbytes, downbytes, and totalbytes are appropriate keys.
3053
3054 All svc_accts which are part of this package have their values incremented.
3055
3056 =cut
3057
3058 sub recharge {
3059   my ($self, $valueref) = @_;
3060
3061   #only svc_acct can set_usage for now
3062   foreach my $cust_svc ( $self->cust_svc( 'svcdb'=>'svc_acct' ) ) {
3063     my $svc_x = $cust_svc->svc_x;
3064     $svc_x->recharge($valueref)
3065       if $svc_x->can("recharge");
3066   }
3067 }
3068
3069 =item cust_pkg_discount
3070
3071 =cut
3072
3073 sub cust_pkg_discount {
3074   my $self = shift;
3075   qsearch('cust_pkg_discount', { 'pkgnum' => $self->pkgnum } );
3076 }
3077
3078 =item cust_pkg_discount_active
3079
3080 =cut
3081
3082 sub cust_pkg_discount_active {
3083   my $self = shift;
3084   grep { $_->status eq 'active' } $self->cust_pkg_discount;
3085 }
3086
3087 =back
3088
3089 =head1 CLASS METHODS
3090
3091 =over 4
3092
3093 =item recurring_sql
3094
3095 Returns an SQL expression identifying recurring packages.
3096
3097 =cut
3098
3099 sub recurring_sql { "
3100   '0' != ( select freq from part_pkg
3101              where cust_pkg.pkgpart = part_pkg.pkgpart )
3102 "; }
3103
3104 =item onetime_sql
3105
3106 Returns an SQL expression identifying one-time packages.
3107
3108 =cut
3109
3110 sub onetime_sql { "
3111   '0' = ( select freq from part_pkg
3112             where cust_pkg.pkgpart = part_pkg.pkgpart )
3113 "; }
3114
3115 =item ordered_sql
3116
3117 Returns an SQL expression identifying ordered packages (recurring packages not
3118 yet billed).
3119
3120 =cut
3121
3122 sub ordered_sql {
3123    $_[0]->recurring_sql. " AND ". $_[0]->not_yet_billed_sql;
3124 }
3125
3126 =item active_sql
3127
3128 Returns an SQL expression identifying active packages.
3129
3130 =cut
3131
3132 sub active_sql {
3133   $_[0]->recurring_sql. "
3134   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3135   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3136   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3137 "; }
3138
3139 =item not_yet_billed_sql
3140
3141 Returns an SQL expression identifying packages which have not yet been billed.
3142
3143 =cut
3144
3145 sub not_yet_billed_sql { "
3146       ( cust_pkg.setup  IS NULL OR cust_pkg.setup  = 0 )
3147   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3148   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3149 "; }
3150
3151 =item inactive_sql
3152
3153 Returns an SQL expression identifying inactive packages (one-time packages
3154 that are otherwise unsuspended/uncancelled).
3155
3156 =cut
3157
3158 sub inactive_sql { "
3159   ". $_[0]->onetime_sql(). "
3160   AND cust_pkg.setup IS NOT NULL AND cust_pkg.setup != 0
3161   AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
3162   AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 )
3163 "; }
3164
3165 =item susp_sql
3166 =item suspended_sql
3167
3168 Returns an SQL expression identifying suspended packages.
3169
3170 =cut
3171
3172 sub suspended_sql { susp_sql(@_); }
3173 sub susp_sql {
3174   #$_[0]->recurring_sql(). ' AND '.
3175   "
3176         ( cust_pkg.cancel IS     NULL  OR cust_pkg.cancel = 0 )
3177     AND   cust_pkg.susp   IS NOT NULL AND cust_pkg.susp  != 0
3178   ";
3179 }
3180
3181 =item cancel_sql
3182 =item cancelled_sql
3183
3184 Returns an SQL exprression identifying cancelled packages.
3185
3186 =cut
3187
3188 sub cancelled_sql { cancel_sql(@_); }
3189 sub cancel_sql { 
3190   #$_[0]->recurring_sql(). ' AND '.
3191   "cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
3192 }
3193
3194 =item status_sql
3195
3196 Returns an SQL expression to give the package status as a string.
3197
3198 =cut
3199
3200 sub status_sql {
3201 "CASE
3202   WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
3203   WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
3204   WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
3205   WHEN ".onetime_sql()." THEN 'one-time charge'
3206   ELSE 'active'
3207 END"
3208 }
3209
3210 =item search HASHREF
3211
3212 (Class method)
3213
3214 Returns a qsearch hash expression to search for parameters specified in HASHREF.
3215 Valid parameters are
3216
3217 =over 4
3218
3219 =item agentnum
3220
3221 =item magic
3222
3223 active, inactive, suspended, cancel (or cancelled)
3224
3225 =item status
3226
3227 active, inactive, suspended, one-time charge, inactive, cancel (or cancelled)
3228
3229 =item custom
3230
3231  boolean selects custom packages
3232
3233 =item classnum
3234
3235 =item pkgpart
3236
3237 pkgpart or arrayref or hashref of pkgparts
3238
3239 =item setup
3240
3241 arrayref of beginning and ending epoch date
3242
3243 =item last_bill
3244
3245 arrayref of beginning and ending epoch date
3246
3247 =item bill
3248
3249 arrayref of beginning and ending epoch date
3250
3251 =item adjourn
3252
3253 arrayref of beginning and ending epoch date
3254
3255 =item susp
3256
3257 arrayref of beginning and ending epoch date
3258
3259 =item expire
3260
3261 arrayref of beginning and ending epoch date
3262
3263 =item cancel
3264
3265 arrayref of beginning and ending epoch date
3266
3267 =item query
3268
3269 pkgnum or APKG_pkgnum
3270
3271 =item cust_fields
3272
3273 a value suited to passing to FS::UI::Web::cust_header
3274
3275 =item CurrentUser
3276
3277 specifies the user for agent virtualization
3278
3279 =item fcc_line
3280
3281 boolean; if true, returns only packages with more than 0 FCC phone lines
3282
3283 =item state, country
3284
3285 Limit to packages whose customer is located in the specified state and 
3286 country.  For FCC 477 reporting.  This will use the customer's service 
3287 address if there is one, but isn't yet smart enough to use the package 
3288 address.
3289
3290 =back
3291
3292 =cut
3293
3294 sub search {
3295   my ($class, $params) = @_;
3296   my @where = ();
3297
3298   ##
3299   # parse agent
3300   ##
3301
3302   if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
3303     push @where,
3304       "cust_main.agentnum = $1";
3305   }
3306
3307   ##
3308   # parse custnum
3309   ##
3310
3311   if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
3312     push @where,
3313       "cust_pkg.custnum = $1";
3314   }
3315
3316   ##
3317   # custbatch
3318   ##
3319
3320   if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
3321     push @where,
3322       "cust_pkg.pkgbatch = '$1'";
3323   }
3324
3325   ##
3326   # parse status
3327   ##
3328
3329   if (    $params->{'magic'}  eq 'active'
3330        || $params->{'status'} eq 'active' ) {
3331
3332     push @where, FS::cust_pkg->active_sql();
3333
3334   } elsif (    $params->{'magic'}  =~ /^not[ _]yet[ _]billed$/
3335             || $params->{'status'} =~ /^not[ _]yet[ _]billed$/ ) {
3336
3337     push @where, FS::cust_pkg->not_yet_billed_sql();
3338
3339   } elsif (    $params->{'magic'}  =~ /^(one-time charge|inactive)/
3340             || $params->{'status'} =~ /^(one-time charge|inactive)/ ) {
3341
3342     push @where, FS::cust_pkg->inactive_sql();
3343
3344   } elsif (    $params->{'magic'}  eq 'suspended'
3345             || $params->{'status'} eq 'suspended'  ) {
3346
3347     push @where, FS::cust_pkg->suspended_sql();
3348
3349   } elsif (    $params->{'magic'}  =~ /^cancell?ed$/
3350             || $params->{'status'} =~ /^cancell?ed$/ ) {
3351
3352     push @where, FS::cust_pkg->cancelled_sql();
3353
3354   }
3355
3356   ###
3357   # parse package class
3358   ###
3359
3360   if ( exists($params->{'classnum'}) ) {
3361
3362     my @classnum = ();
3363     if ( ref($params->{'classnum'}) ) {
3364
3365       if ( ref($params->{'classnum'}) eq 'HASH' ) {
3366         @classnum = grep $params->{'classnum'}{$_}, keys %{ $params->{'classnum'} };
3367       } elsif ( ref($params->{'classnum'}) eq 'ARRAY' ) {
3368         @classnum = @{ $params->{'classnum'} };
3369       } else {
3370         die 'unhandled classnum ref '. $params->{'classnum'};
3371       }
3372
3373
3374     } elsif ( $params->{'classnum'} =~ /^(\d*)$/ && $1 ne '0' ) {
3375       @classnum = ( $1 );
3376     }
3377
3378     if ( @classnum ) {
3379
3380       my @c_where = ();
3381       my @nums = grep $_, @classnum;
3382       push @c_where, 'part_pkg.classnum IN ('. join(',',@nums). ')' if @nums;
3383       my $null = scalar( grep { $_ eq '' } @classnum );
3384       push @c_where, 'part_pkg.classnum IS NULL' if $null;
3385
3386       if ( scalar(@c_where) == 1 ) {
3387         push @where, @c_where;
3388       } elsif ( @c_where ) {
3389         push @where, ' ( '. join(' OR ', @c_where). ' ) ';
3390       }
3391
3392     }
3393     
3394
3395   }
3396
3397   ###
3398   # parse package report options
3399   ###
3400
3401   my @report_option = ();
3402   if ( exists($params->{'report_option'}) ) {
3403     if ( ref($params->{'report_option'}) eq 'ARRAY' ) {
3404       @report_option = @{ $params->{'report_option'} };
3405     } elsif ( $params->{'report_option'} =~ /^([,\d]*)$/ ) {
3406       @report_option = split(',', $1);
3407     }
3408
3409   }
3410
3411   if (@report_option) {
3412     # this will result in the empty set for the dangling comma case as it should
3413     push @where, 
3414       map{ "0 < ( SELECT count(*) FROM part_pkg_option
3415                     WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3416                     AND optionname = 'report_option_$_'
3417                     AND optionvalue = '1' )"
3418          } @report_option;
3419   }
3420
3421   foreach my $any ( grep /^report_option_any/, keys %$params ) {
3422
3423     my @report_option_any = ();
3424     if ( ref($params->{$any}) eq 'ARRAY' ) {
3425       @report_option_any = @{ $params->{$any} };
3426     } elsif ( $params->{$any} =~ /^([,\d]*)$/ ) {
3427       @report_option_any = split(',', $1);
3428     }
3429
3430     if (@report_option_any) {
3431       # this will result in the empty set for the dangling comma case as it should
3432       push @where, ' ( '. join(' OR ',
3433         map{ "0 < ( SELECT count(*) FROM part_pkg_option
3434                       WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
3435                       AND optionname = 'report_option_$_'
3436                       AND optionvalue = '1' )"
3437            } @report_option_any
3438       ). ' ) ';
3439     }
3440
3441   }
3442
3443   ###
3444   # parse custom
3445   ###
3446
3447   push @where,  "part_pkg.custom = 'Y'" if $params->{custom};
3448
3449   ###
3450   # parse fcc_line
3451   ###
3452
3453   push @where,  "(part_pkg.fcc_ds0s > 0 OR pkg_class.fcc_ds0s > 0)" 
3454                                                         if $params->{fcc_line};
3455
3456   ###
3457   # parse censustract
3458   ###
3459
3460   if ( exists($params->{'censustract'}) ) {
3461     $params->{'censustract'} =~ /^([.\d]*)$/;
3462     my $censustract = "cust_main.censustract = '$1'";
3463     $censustract .= ' OR cust_main.censustract is NULL' unless $1;
3464     push @where,  "( $censustract )";
3465   }
3466
3467   ###
3468   # parse censustract2
3469   ###
3470   if ( exists($params->{'censustract2'})
3471        && $params->{'censustract2'} =~ /^(\d*)$/
3472      )
3473   {
3474     if ($1) {
3475       push @where, "cust_main.censustract LIKE '$1%'";
3476     } else {
3477       push @where,
3478         "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
3479     }
3480   }
3481
3482   ###
3483   # parse country/state
3484   ###
3485
3486   for (qw(state country)) {
3487     if ( exists($params->{$_})
3488       && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
3489     {
3490       push @where, 
3491         "COALESCE(cust_location.$_, cust_main.ship_$_, cust_main.$_) = '$1'";
3492     }
3493   }
3494
3495
3496   ###
3497   # parse part_pkg
3498   ###
3499
3500   if ( ref($params->{'pkgpart'}) ) {
3501
3502     my @pkgpart = ();
3503     if ( ref($params->{'pkgpart'}) eq 'HASH' ) {
3504       @pkgpart = grep $params->{'pkgpart'}{$_}, keys %{ $params->{'pkgpart'} };
3505     } elsif ( ref($params->{'pkgpart'}) eq 'ARRAY' ) {
3506       @pkgpart = @{ $params->{'pkgpart'} };
3507     } else {
3508       die 'unhandled pkgpart ref '. $params->{'pkgpart'};
3509     }
3510
3511     @pkgpart = grep /^(\d+)$/, @pkgpart;
3512
3513     push @where, 'pkgpart IN ('. join(',', @pkgpart). ')' if scalar(@pkgpart);
3514
3515   } elsif ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3516     push @where, "pkgpart = $1";
3517   } 
3518
3519   ###
3520   # parse dates
3521   ###
3522
3523   my $orderby = '';
3524
3525   #false laziness w/report_cust_pkg.html
3526   my %disable = (
3527     'all'             => {},
3528     'one-time charge' => { 'last_bill'=>1, 'bill'=>1, 'adjourn'=>1, 'susp'=>1, 'expire'=>1, 'cancel'=>1, },
3529     'active'          => { 'susp'=>1, 'cancel'=>1 },
3530     'suspended'       => { 'cancel' => 1 },
3531     'cancelled'       => {},
3532     ''                => {},
3533   );
3534
3535   if( exists($params->{'active'} ) ) {
3536     # This overrides all the other date-related fields
3537     my($beginning, $ending) = @{$params->{'active'}};
3538     push @where,
3539       "cust_pkg.setup IS NOT NULL",
3540       "cust_pkg.setup <= $ending",
3541       "(cust_pkg.cancel IS NULL OR cust_pkg.cancel >= $beginning )",
3542       "NOT (".FS::cust_pkg->onetime_sql . ")";
3543   }
3544   else {
3545     foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end change_date cancel )) {
3546
3547       next unless exists($params->{$field});
3548
3549       my($beginning, $ending) = @{$params->{$field}};
3550
3551       next if $beginning == 0 && $ending == 4294967295;
3552
3553       push @where,
3554         "cust_pkg.$field IS NOT NULL",
3555         "cust_pkg.$field >= $beginning",
3556         "cust_pkg.$field <= $ending";
3557
3558       $orderby ||= "ORDER BY cust_pkg.$field";
3559
3560     }
3561   }
3562
3563   $orderby ||= 'ORDER BY bill';
3564
3565   ###
3566   # parse magic, legacy, etc.
3567   ###
3568
3569   if ( $params->{'magic'} &&
3570        $params->{'magic'} =~ /^(active|inactive|suspended|cancell?ed)$/
3571   ) {
3572
3573     $orderby = 'ORDER BY pkgnum';
3574
3575     if ( $params->{'pkgpart'} =~ /^(\d+)$/ ) {
3576       push @where, "pkgpart = $1";
3577     }
3578
3579   } elsif ( $params->{'query'} eq 'pkgnum' ) {
3580
3581     $orderby = 'ORDER BY pkgnum';
3582
3583   } elsif ( $params->{'query'} eq 'APKG_pkgnum' ) {
3584
3585     $orderby = 'ORDER BY pkgnum';
3586
3587     push @where, '0 < (
3588       SELECT count(*) FROM pkg_svc
3589        WHERE pkg_svc.pkgpart =  cust_pkg.pkgpart
3590          AND pkg_svc.quantity > ( SELECT count(*) FROM cust_svc
3591                                    WHERE cust_svc.pkgnum  = cust_pkg.pkgnum
3592                                      AND cust_svc.svcpart = pkg_svc.svcpart
3593                                 )
3594     )';
3595   
3596   }
3597
3598   ##
3599   # setup queries, links, subs, etc. for the search
3600   ##
3601
3602   # here is the agent virtualization
3603   if ($params->{CurrentUser}) {
3604     my $access_user =
3605       qsearchs('access_user', { username => $params->{CurrentUser} });
3606
3607     if ($access_user) {
3608       push @where, $access_user->agentnums_sql('table'=>'cust_main');
3609     } else {
3610       push @where, "1=0";
3611     }
3612   } else {
3613     push @where, $FS::CurrentUser::CurrentUser->agentnums_sql('table'=>'cust_main');
3614   }
3615
3616   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
3617
3618   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
3619                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
3620                   'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
3621                   'LEFT JOIN cust_location USING ( locationnum ) ';
3622
3623   my $select;
3624   my $count_query;
3625   if ( $params->{'select_zip5'} ) {
3626     my $zip = 'COALESCE(cust_location.zip, cust_main.ship_zip, cust_main.zip)';
3627
3628     $select = "DISTINCT substr($zip,1,5) as zip";
3629     $orderby = "ORDER BY substr($zip,1,5)";
3630     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
3631   } else {
3632     $select = join(', ',
3633                          'cust_pkg.*',
3634                          ( map "part_pkg.$_", qw( pkg freq ) ),
3635                          'pkg_class.classname',
3636                          'cust_main.custnum AS cust_main_custnum',
3637                          FS::UI::Web::cust_sql_fields(
3638                            $params->{'cust_fields'}
3639                          ),
3640                   );
3641     $count_query = 'SELECT COUNT(*)';
3642   }
3643
3644   $count_query .= " FROM cust_pkg $addl_from $extra_sql";
3645
3646   my $sql_query = {
3647     'table'       => 'cust_pkg',
3648     'hashref'     => {},
3649     'select'      => $select,
3650     'extra_sql'   => $extra_sql,
3651     'order_by'    => $orderby,
3652     'addl_from'   => $addl_from,
3653     'count_query' => $count_query,
3654   };
3655
3656 }
3657
3658 =item fcc_477_count
3659
3660 Returns a list of two package counts.  The first is a count of packages
3661 based on the supplied criteria and the second is the count of residential
3662 packages with those same criteria.  Criteria are specified as in the search
3663 method.
3664
3665 =cut
3666
3667 sub fcc_477_count {
3668   my ($class, $params) = @_;
3669
3670   my $sql_query = $class->search( $params );
3671
3672   my $count_sql = delete($sql_query->{'count_query'});
3673   $count_sql =~ s/ FROM/,count(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN 1 END) FROM/
3674     or die "couldn't parse count_sql";
3675
3676   my $count_sth = dbh->prepare($count_sql)
3677     or die "Error preparing $count_sql: ". dbh->errstr;
3678   $count_sth->execute
3679     or die "Error executing $count_sql: ". $count_sth->errstr;
3680   my $count_arrayref = $count_sth->fetchrow_arrayref;
3681
3682   return ( @$count_arrayref );
3683
3684 }
3685
3686
3687 =item location_sql
3688
3689 Returns a list: the first item is an SQL fragment identifying matching 
3690 packages/customers via location (taking into account shipping and package
3691 address taxation, if enabled), and subsequent items are the parameters to
3692 substitute for the placeholders in that fragment.
3693
3694 =cut
3695
3696 sub location_sql {
3697   my($class, %opt) = @_;
3698   my $ornull = $opt{'ornull'};
3699
3700   my $conf = new FS::Conf;
3701
3702   # '?' placeholders in _location_sql_where
3703   my $x = $ornull ? 3 : 2;
3704   my @bill_param = ( 
3705     ('district')x3,
3706     ('city')x3, 
3707     ('county')x$x,
3708     ('state')x$x,
3709     'country'
3710   );
3711
3712   my $main_where;
3713   my @main_param;
3714   if ( $conf->exists('tax-ship_address') ) {
3715
3716     $main_where = "(
3717          (     ( ship_last IS NULL     OR  ship_last  = '' )
3718            AND ". _location_sql_where('cust_main', '', $ornull ). "
3719          )
3720       OR (       ship_last IS NOT NULL AND ship_last != ''
3721            AND ". _location_sql_where('cust_main', 'ship_', $ornull ). "
3722          )
3723     )";
3724     #    AND payby != 'COMP'
3725
3726     @main_param = ( @bill_param, @bill_param );
3727
3728   } else {
3729
3730     $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP'
3731     @main_param = @bill_param;
3732
3733   }
3734
3735   my $where;
3736   my @param;
3737   if ( $conf->exists('tax-pkg_address') ) {
3738
3739     my $loc_where = _location_sql_where( 'cust_location', '', $ornull );
3740
3741     $where = " (
3742                     ( cust_pkg.locationnum IS     NULL AND $main_where )
3743                  OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where  )
3744                )
3745              ";
3746     @param = ( @main_param, @bill_param );
3747   
3748   } else {
3749
3750     $where = $main_where;
3751     @param = @main_param;
3752
3753   }
3754
3755   ( $where, @param );
3756
3757 }
3758
3759 #subroutine, helper for location_sql
3760 sub _location_sql_where {
3761   my $table  = shift;
3762   my $prefix = @_ ? shift : '';
3763   my $ornull = @_ ? shift : '';
3764
3765 #  $ornull             = $ornull          ? " OR ( ? IS NULL AND $table.${prefix}county IS NULL ) " : '';
3766
3767   $ornull = $ornull ? ' OR ? IS NULL ' : '';
3768
3769   my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
3770   my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
3771   my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
3772
3773   my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
3774
3775 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
3776   "
3777         ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
3778     AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
3779     AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
3780     AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
3781     AND   $table.${prefix}country  = ?
3782   ";
3783 }
3784
3785 sub _X_show_zero {
3786   my( $self, $what ) = @_;
3787
3788   my $what_show_zero = $what. '_show_zero';
3789   length($self->$what_show_zero())
3790     ? ($self->$what_show_zero() eq 'Y')
3791     : $self->part_pkg->$what_show_zero();
3792 }
3793
3794 =head1 SUBROUTINES
3795
3796 =over 4
3797
3798 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
3799
3800 CUSTNUM is a customer (see L<FS::cust_main>)
3801
3802 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3803 L<FS::part_pkg>) to order for this customer.  Duplicates are of course
3804 permitted.
3805
3806 REMOVE_PKGNUMS is an optional list of pkgnums specifying the billing items to
3807 remove for this customer.  The services (see L<FS::cust_svc>) are moved to the
3808 new billing items.  An error is returned if this is not possible (see
3809 L<FS::pkg_svc>).  An empty arrayref is equivalent to not specifying this
3810 parameter.
3811
3812 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3813 newly-created cust_pkg objects.
3814
3815 REFNUM, if specified, will specify the FS::pkg_referral record to be created
3816 and inserted.  Multiple FS::pkg_referral records can be created by
3817 setting I<refnum> to an array reference of refnums or a hash reference with
3818 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
3819 record will be created corresponding to cust_main.refnum.
3820
3821 =cut
3822
3823 sub order {
3824   my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
3825
3826   my $conf = new FS::Conf;
3827
3828   # Transactionize this whole mess
3829   local $SIG{HUP} = 'IGNORE';
3830   local $SIG{INT} = 'IGNORE'; 
3831   local $SIG{QUIT} = 'IGNORE';
3832   local $SIG{TERM} = 'IGNORE';
3833   local $SIG{TSTP} = 'IGNORE'; 
3834   local $SIG{PIPE} = 'IGNORE'; 
3835
3836   my $oldAutoCommit = $FS::UID::AutoCommit;
3837   local $FS::UID::AutoCommit = 0;
3838   my $dbh = dbh;
3839
3840   my $error;
3841 #  my $cust_main = qsearchs('cust_main', { custnum => $custnum });
3842 #  return "Customer not found: $custnum" unless $cust_main;
3843
3844   warn "$me order: pkgnums to remove: ". join(',', @$remove_pkgnum). "\n"
3845     if $DEBUG;
3846
3847   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3848                          @$remove_pkgnum;
3849
3850   my $change = scalar(@old_cust_pkg) != 0;
3851
3852   my %hash = (); 
3853   if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) {
3854
3855     warn "$me order: changing pkgnum ". $old_cust_pkg[0]->pkgnum.
3856          " to pkgpart ". $pkgparts->[0]. "\n"
3857       if $DEBUG;
3858
3859     my $err_or_cust_pkg =
3860       $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0],
3861                                 'refnum'  => $refnum,
3862                               );
3863
3864     unless (ref($err_or_cust_pkg)) {
3865       $dbh->rollback if $oldAutoCommit;
3866       return $err_or_cust_pkg;
3867     }
3868
3869     push @$return_cust_pkg, $err_or_cust_pkg;
3870     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3871     return '';
3872
3873   }
3874
3875   # Create the new packages.
3876   foreach my $pkgpart (@$pkgparts) {
3877
3878     warn "$me order: inserting pkgpart $pkgpart\n" if $DEBUG;
3879
3880     my $cust_pkg = new FS::cust_pkg { custnum => $custnum,
3881                                       pkgpart => $pkgpart,
3882                                       refnum  => $refnum,
3883                                       %hash,
3884                                     };
3885     $error = $cust_pkg->insert( 'change' => $change );
3886     if ($error) {
3887       $dbh->rollback if $oldAutoCommit;
3888       return $error;
3889     }
3890     push @$return_cust_pkg, $cust_pkg;
3891   }
3892   # $return_cust_pkg now contains refs to all of the newly 
3893   # created packages.
3894
3895   # Transfer services and cancel old packages.
3896   foreach my $old_pkg (@old_cust_pkg) {
3897
3898     warn "$me order: transferring services from pkgnum ". $old_pkg->pkgnum. "\n"
3899       if $DEBUG;
3900
3901     foreach my $new_pkg (@$return_cust_pkg) {
3902       $error = $old_pkg->transfer($new_pkg);
3903       if ($error and $error == 0) {
3904         # $old_pkg->transfer failed.
3905         $dbh->rollback if $oldAutoCommit;
3906         return $error;
3907       }
3908     }
3909
3910     if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) {
3911       warn "trying transfer again with change_svcpart option\n" if $DEBUG;
3912       foreach my $new_pkg (@$return_cust_pkg) {
3913         $error = $old_pkg->transfer($new_pkg, 'change_svcpart'=>1 );
3914         if ($error and $error == 0) {
3915           # $old_pkg->transfer failed.
3916         $dbh->rollback if $oldAutoCommit;
3917         return $error;
3918         }
3919       }
3920     }
3921
3922     if ($error > 0) {
3923       # Transfers were successful, but we went through all of the 
3924       # new packages and still had services left on the old package.
3925       # We can't cancel the package under the circumstances, so abort.
3926       $dbh->rollback if $oldAutoCommit;
3927       return "Unable to transfer all services from package ".$old_pkg->pkgnum;
3928     }
3929     $error = $old_pkg->cancel( quiet=>1 );
3930     if ($error) {
3931       $dbh->rollback;
3932       return $error;
3933     }
3934   }
3935   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3936   '';
3937 }
3938
3939 =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ]
3940
3941 A bulk change method to change packages for multiple customers.
3942
3943 PKGPARTS is a list of pkgparts specifying the the billing item definitions (see
3944 L<FS::part_pkg>) to order for each customer.  Duplicates are of course
3945 permitted.
3946
3947 REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to
3948 replace.  The services (see L<FS::cust_svc>) are moved to the
3949 new billing items.  An error is returned if this is not possible (see
3950 L<FS::pkg_svc>).
3951
3952 RETURN_CUST_PKG_ARRAYREF, if specified, will be filled in with the
3953 newly-created cust_pkg objects.
3954
3955 =cut
3956
3957 sub bulk_change {
3958   my ($pkgparts, $remove_pkgnum, $return_cust_pkg) = @_;
3959
3960   # Transactionize this whole mess
3961   local $SIG{HUP} = 'IGNORE';
3962   local $SIG{INT} = 'IGNORE'; 
3963   local $SIG{QUIT} = 'IGNORE';
3964   local $SIG{TERM} = 'IGNORE';
3965   local $SIG{TSTP} = 'IGNORE'; 
3966   local $SIG{PIPE} = 'IGNORE'; 
3967
3968   my $oldAutoCommit = $FS::UID::AutoCommit;
3969   local $FS::UID::AutoCommit = 0;
3970   my $dbh = dbh;
3971
3972   my @errors;
3973   my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) }
3974                          @$remove_pkgnum;
3975
3976   while(scalar(@old_cust_pkg)) {
3977     my @return = ();
3978     my $custnum = $old_cust_pkg[0]->custnum;
3979     my (@remove) = map { $_->pkgnum }
3980                    grep { $_->custnum == $custnum } @old_cust_pkg;
3981     @old_cust_pkg = grep { $_->custnum != $custnum } @old_cust_pkg;
3982
3983     my $error = order $custnum, $pkgparts, \@remove, \@return;
3984
3985     push @errors, $error
3986       if $error;
3987     push @$return_cust_pkg, @return;
3988   }
3989
3990   if (scalar(@errors)) {
3991     $dbh->rollback if $oldAutoCommit;
3992     return join(' / ', @errors);
3993   }
3994
3995   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3996   '';
3997 }
3998
3999 # Used by FS::Upgrade to migrate to a new database.
4000 sub _upgrade_data {  # class method
4001   my ($class, %opts) = @_;
4002   $class->_upgrade_otaker(%opts);
4003   my @statements = (
4004     # RT#10139, bug resulting in contract_end being set when it shouldn't
4005   'UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1',
4006     # RT#10830, bad calculation of prorate date near end of year
4007     # the date range for bill is December 2009, and we move it forward
4008     # one year if it's before the previous bill date (which it should 
4009     # never be)
4010   'UPDATE cust_pkg SET bill = bill + (365*24*60*60) WHERE bill < last_bill
4011   AND bill > 1259654400 AND bill < 1262332800 AND (SELECT plan FROM part_pkg 
4012   WHERE part_pkg.pkgpart = cust_pkg.pkgpart) = \'prorate\'',
4013     # RT6628, add order_date to cust_pkg
4014     'update cust_pkg set order_date = (select history_date from h_cust_pkg 
4015         where h_cust_pkg.pkgnum = cust_pkg.pkgnum and 
4016         history_action = \'insert\') where order_date is null',
4017   );
4018   foreach my $sql (@statements) {
4019     my $sth = dbh->prepare($sql);
4020     $sth->execute or die $sth->errstr;
4021   }
4022 }
4023
4024 =back
4025
4026 =head1 BUGS
4027
4028 sub order is not OO.  Perhaps it should be moved to FS::cust_main and made so?
4029
4030 In sub order, the @pkgparts array (passed by reference) is clobbered.
4031
4032 Also in sub order, no money is adjusted.  Once FS::part_pkg defines a standard
4033 method to pass dates to the recur_prog expression, it should do so.
4034
4035 FS::svc_acct, FS::svc_domain, FS::svc_www, FS::svc_ip and FS::svc_forward are
4036 loaded via 'use' at compile time, rather than via 'require' in sub { setup,
4037 suspend, unsuspend, cancel } because they use %FS::UID::callback to load
4038 configuration values.  Probably need a subroutine which decides what to do
4039 based on whether or not we've fetched the user yet, rather than a hash.  See
4040 FS::UID and the TODO.
4041
4042 Now that things are transactional should the check in the insert method be
4043 moved to check ?
4044
4045 =head1 SEE ALSO
4046
4047 L<FS::Record>, L<FS::cust_main>, L<FS::part_pkg>, L<FS::cust_svc>,
4048 L<FS::pkg_svc>, schema.html from the base documentation
4049
4050 =cut
4051
4052 1;
4053