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