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