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