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