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