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