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