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