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