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