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