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