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